diff --git a/src/main/java/ee/carlrobert/codegpt/settings/service/custom/CustomServiceFormTabbedPane.java b/src/main/java/ee/carlrobert/codegpt/settings/service/custom/CustomServiceFormTabbedPane.java deleted file mode 100644 index acb49790..00000000 --- a/src/main/java/ee/carlrobert/codegpt/settings/service/custom/CustomServiceFormTabbedPane.java +++ /dev/null @@ -1,135 +0,0 @@ -package ee.carlrobert.codegpt.settings.service.custom; - -import static java.util.stream.Collectors.toMap; - -import com.intellij.ui.ToolbarDecorator; -import com.intellij.ui.components.JBTabbedPane; -import com.intellij.ui.table.JBTable; -import com.intellij.util.ui.JBUI; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import javax.swing.JPanel; -import javax.swing.table.DefaultTableModel; - -public class CustomServiceFormTabbedPane extends JBTabbedPane { - - private final JBTable headersTable; - private final JBTable bodyTable; - - public CustomServiceFormTabbedPane(Map headers, Map body) { - headersTable = new JBTable( - new DefaultTableModel(toArray(headers), - new Object[]{"Key", "Value"})); - - bodyTable = new JBTable( - new DefaultTableModel(toArray(body), - new Object[]{"Key", "Value"})); - bodyTable.setVisibleRowCount(6); - - setTabComponentInsets(JBUI.insetsTop(8)); - addTab("Headers", createTablePanel(headersTable)); - addTab("Body", createTablePanel(bodyTable)); - } - - public void setEnabled(boolean enabled) { - headersTable.setEnabled(enabled); - bodyTable.setEnabled(enabled); - } - - public void setHeaders(Map headers) { - setTableData(headersTable, headers); - } - - public Map getHeaders() { - return getTableData(headersTable).entrySet().stream() - .filter(entry -> entry.getKey() != null && entry.getValue() != null) - .collect(toMap(Entry::getKey, entry -> String.valueOf(entry.getValue()))); - } - - public void setBody(Map body) { - setTableData(bodyTable, body); - } - - public Map getBody() { - return getTableData(bodyTable); - } - - private void setTableData(JBTable table, Map values) { - DefaultTableModel model = (DefaultTableModel) table.getModel(); - - if (hasTableDataChanged(model, values)) { - model.setRowCount(0); - for (var entry : values.entrySet()) { - model.addRow(new Object[]{entry.getKey(), entry.getValue()}); - } - } - } - - private boolean hasTableDataChanged(DefaultTableModel model, Map newValues) { - if (model.getRowCount() != newValues.size()) { - return true; - } - - for (int i = 0; i < model.getRowCount(); i++) { - String key = (String) model.getValueAt(i, 0); - Object value = model.getValueAt(i, 1); - - if (!newValues.containsKey(key) || !java.util.Objects.equals(newValues.get(key), value)) { - return true; - } - } - - return false; - } - - private Map getTableData(JBTable table) { - var model = (DefaultTableModel) table.getModel(); - var data = new HashMap(); - for (int i = 0; i < model.getRowCount(); i++) { - var key = (String) model.getValueAt(i, 0); - data.put(key, parseValue(model.getValueAt(i, 1))); - } - return data; - } - - private static Object parseValue(Object value) { - if (!(value instanceof String stringValue)) { - return value; - } - - try { - return Integer.parseInt(stringValue); - } catch (NumberFormatException e) { - // ignore - } - try { - return Double.parseDouble(stringValue); - } catch (NumberFormatException e) { - // ignore - } - if (List.of("true", "false").contains(stringValue.toLowerCase().trim())) { - return Boolean.parseBoolean(stringValue); - } - return value; - } - - public static Object[][] toArray(Map actionsMap) { - return actionsMap.entrySet() - .stream() - .map(entry -> new Object[]{entry.getKey(), entry.getValue()}) - .toArray(Object[][]::new); - } - - private JPanel createTablePanel(JBTable table) { - return ToolbarDecorator.createDecorator(table) - .setAddAction(anActionButton -> - ((DefaultTableModel) table.getModel()).addRow(new Object[]{"", null})) - .setRemoveAction(anActionButton -> - ((DefaultTableModel) table.getModel()).removeRow(table.getSelectedRow())) - .disableUpAction() - .disableDownAction() - .createPanel(); - } -} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIChatCompletion.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIChatCompletion.kt index 550e6867..0e421fcb 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIChatCompletion.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIChatCompletion.kt @@ -1,13 +1,12 @@ package ee.carlrobert.codegpt.agent.clients import ai.koog.prompt.executor.clients.openai.base.models.* -import ai.koog.prompt.executor.clients.serialization.AdditionalPropertiesFlatteningSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @Serializable -internal class CustomOpenAIChatCompletionRequest( +class CustomOpenAIChatCompletionRequest( val messages: List = emptyList(), val prompt: String? = null, override val model: String? = null, @@ -156,8 +155,3 @@ public class CustomOpenAIChatCompletionStreamResponse( public val objectType: String = "chat.completion.chunk", public val usage: OpenAIUsage? = null, ) : OpenAIBaseLLMStreamResponse - -internal object CustomOpenAIChatCompletionRequestSerializer : - AdditionalPropertiesFlatteningSerializer( - CustomOpenAIChatCompletionRequest.serializer() - ) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAILLMClient.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAILLMClient.kt index 6508b3f6..923e86ab 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAILLMClient.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAILLMClient.kt @@ -9,7 +9,6 @@ import ai.koog.prompt.executor.clients.openai.base.AbstractOpenAILLMClient import ai.koog.prompt.executor.clients.openai.base.OpenAIBaseSettings import ai.koog.prompt.executor.clients.openai.base.OpenAICompatibleToolDescriptorSchemaGenerator import ai.koog.prompt.executor.clients.openai.base.models.* -import ai.koog.prompt.executor.clients.openai.models.OpenAIChatCompletionStreamResponse import ai.koog.prompt.llm.LLMProvider import ai.koog.prompt.llm.LLModel import ai.koog.prompt.message.LLMChoice @@ -17,13 +16,21 @@ import ai.koog.prompt.message.Message import ai.koog.prompt.message.ResponseMetaInfo import ai.koog.prompt.params.LLMParams import ai.koog.prompt.streaming.StreamFrame -import ai.koog.prompt.streaming.StreamFrameFlowBuilder import ai.koog.prompt.streaming.buildStreamFrameFlow import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* import kotlinx.coroutines.flow.Flow import kotlinx.datetime.Clock +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import java.net.URI import kotlin.io.encoding.ExperimentalEncodingApi @@ -33,7 +40,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi * @property baseUrl The base URL of the CustomOpenAI API. Default is "https://CustomOpenAI.ai/api/v1". * @property timeoutConfig Configuration for connection timeouts including request, connection, and socket timeouts. */ -public class CustomOpenAIClientSettings( +class CustomOpenAIClientSettings( baseUrl: String, chatCompletionsPath: String, timeoutConfig: ConnectionTimeoutConfig @@ -47,7 +54,7 @@ public class CustomOpenAIClientSettings( * @param settings The base URL and timeouts for the CustomOpenAI API, defaults to "https://CustomOpenAI.ai" and 900s * @param clock Clock instance used for tracking response metadata timestamps. */ -public class CustomOpenAILLMClient( +class CustomOpenAILLMClient( apiKey: String, private val settings: CustomOpenAIClientSettings, private val state: CustomServiceChatCompletionSettingsState, @@ -61,8 +68,7 @@ public class CustomOpenAILLMClient( staticLogger, OpenAICompatibleToolDescriptorSchemaGenerator() ) { - public data object CustomOpenAI : LLMProvider("custom-openai", "Custom OpenAI") - + data object CustomOpenAI : LLMProvider("custom-openai", "Custom OpenAI") companion object { private val staticLogger = KotlinLogging.logger { } @@ -85,7 +91,24 @@ public class CustomOpenAILLMClient( chatCompletionsPath = uri.path, timeoutConfig = timeoutConfig ) - return CustomOpenAILLMClient(apiKey, settings, state, baseClient) + val clientWithCustomHeaders = baseClient.config { + defaultRequest { + state.headers.forEach { (key, value) -> + val normalizedKey = key.trim() + if (normalizedKey.isEmpty() + || normalizedKey.equals("Authorization", ignoreCase = true) + ) { + return@forEach + } + + header( + normalizedKey, + value.replace($$"$CUSTOM_SERVICE_API_KEY", apiKey) + ) + } + } + } + return CustomOpenAILLMClient(apiKey, settings, state, clientWithCustomHeaders) } } @@ -100,8 +123,7 @@ public class CustomOpenAILLMClient( stream: Boolean ): String { val customParams = params.toCustomOpenAIParams(state) - val responseFormat = createResponseFormat(params.schema, model) - + val responseFormat = createResponseFormat(customParams.schema, model) val request = CustomOpenAIChatCompletionRequest( messages = messages, model = model.id, @@ -182,60 +204,110 @@ public class CustomOpenAILLMClient( finishReason: String?, metaInfo: ResponseMetaInfo ): List { - return when { - this is OpenAIMessage.Assistant && !this.toolCalls.isNullOrEmpty() -> { - val result = mutableListOf() - if (this.content != null) { - result.add( - Message.Assistant( - content = this.content!!.text(), - finishReason = finishReason, - metaInfo = metaInfo - ) - ) - } + val contentText = content?.text() - this.toolCalls!!.forEach { toolCall -> - result.add( - Message.Tool.Call( - id = toolCall.id, - tool = toolCall.function.name, - content = toolCall.function.arguments.takeIf { it.isNotEmpty() } - ?: "{}", - metaInfo = metaInfo + if (this is OpenAIMessage.Assistant) { + val assistantToolCalls = toolCalls + if (!assistantToolCalls.isNullOrEmpty()) { + return buildList { + contentText?.let { + add( + Message.Assistant( + content = it, + finishReason = finishReason, + metaInfo = metaInfo + ) ) - ) + } + + assistantToolCalls.forEach { toolCall -> + add( + Message.Tool.Call( + id = toolCall.id, + tool = toolCall.function.name, + content = toolCall.function.arguments.takeIf { it.isNotEmpty() } + ?: "{}", + metaInfo = metaInfo + ) + ) + } } - return result } - this is OpenAIMessage.Assistant && this.reasoningContent != null && this.content != null -> listOf( - Message.Reasoning( - content = this.reasoningContent!!, - metaInfo = metaInfo - ), + val reasoning = reasoningContent + if (reasoning != null && contentText != null) { + return listOf( + Message.Reasoning( + content = reasoning, + metaInfo = metaInfo + ), + Message.Assistant( + content = contentText, + finishReason = finishReason, + metaInfo = metaInfo + ) + ) + } + } + + if (contentText != null) { + return listOf( Message.Assistant( - content = this.content!!.text(), + content = contentText, finishReason = finishReason, metaInfo = metaInfo ) ) + } - this.content != null -> listOf( - Message.Assistant( - content = this.content!!.text(), - finishReason = finishReason, - metaInfo = metaInfo - ) - ) + val exception = LLMClientException( + clientName, + "Unexpected response: no tool calls and no content" + ) + logger.error(exception) { exception.message } + throw exception + } +} - else -> { - val exception = LLMClientException( - clientName, - "Unexpected response: no tool calls and no content" +internal object CustomOpenAIChatCompletionRequestSerializer : + CustomOpenAIAdditionalPropertiesFlatteningSerializer(CustomOpenAIChatCompletionRequest.serializer()) + +abstract class CustomOpenAIAdditionalPropertiesFlatteningSerializer(tSerializer: KSerializer) : + JsonTransformingSerializer(tSerializer) { + + private val additionalPropertiesField = "additional_properties" + + @OptIn(ExperimentalSerializationApi::class) + private val knownProperties = tSerializer.descriptor.elementNames + + override fun transformSerialize(element: JsonElement): JsonElement { + val obj = element.jsonObject + + return buildJsonObject { + obj.entries.asSequence() + .filterNot { (key, _) -> key == additionalPropertiesField } + .forEach { (key, value) -> put(key, value) } + + obj[additionalPropertiesField]?.jsonObject?.entries + ?.filterNot { (key, _) -> obj.containsKey(key) } + ?.forEach { (key, value) -> put(key, value) } + } + } + + override fun transformDeserialize(element: JsonElement): JsonElement { + val obj = element.jsonObject + val (known, additional) = obj.entries.partition { (key, _) -> key in knownProperties } + + return buildJsonObject { + known.forEach { (key, value) -> put(key, value) } + + if (additional.isNotEmpty()) { + put( + additionalPropertiesField, + buildJsonObject { + additional.forEach { (key, value) -> put(key, value) } + } ) - logger.error(exception) { exception.message } - throw exception } } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIParams.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIParams.kt index 240207e9..4627ae5a 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIParams.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIParams.kt @@ -1,26 +1,159 @@ package ee.carlrobert.codegpt.agent.clients import ai.koog.prompt.params.LLMParams -import ai.koog.prompt.params.LLMParams.Schema import ai.koog.prompt.params.LLMParams.ToolChoice import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState -import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.* internal fun LLMParams.toCustomOpenAIParams(state: CustomServiceChatCompletionSettingsState): CustomOpenAIParams { - if (this is CustomOpenAIParams) return this + val current = this as? CustomOpenAIParams + val body = state.body + + val mergedAdditionalProperties = buildMap { + body + .filterKeys { it !in RESERVED_BODY_KEYS } + .forEach { (key, value) -> put(key, value.toJsonElement()) } + }.takeIf { it.isNotEmpty() } + return CustomOpenAIParams( - temperature = state.body["temperature"] as? Double ?: temperature, - maxTokens = state.body["maxTokens"] as? Int ?: maxTokens, - numberOfChoices = state.body["numberOfChoices"] as? Int ?: numberOfChoices, - speculation = state.body["speculation"] as? String ?: speculation, - schema = state.body["schema"] as? Schema ?: schema, - toolChoice = state.body["toolChoice"] as? ToolChoice ?: toolChoice, - user = state.body["user"] as? String ?: user, - additionalProperties = state.body["additionalProperties"] as? Map - ?: additionalProperties, + additionalProperties = mergedAdditionalProperties, + temperature = body.findValue("temperature").asDouble() ?: temperature, + maxTokens = body.findValue("maxTokens", "max_tokens").asInt() ?: maxTokens, + speculation = body.findValue("speculation").asString() ?: speculation, + toolChoice = body.findValue("toolChoice", "tool_choice").asToolChoice() ?: toolChoice, + frequencyPenalty = body.findValue("frequencyPenalty", "frequency_penalty").asDouble() + ?: current?.frequencyPenalty, + presencePenalty = body.findValue("presencePenalty", "presence_penalty").asDouble() + ?: current?.presencePenalty, + stop = body.findValue("stop").asStopList() ?: current?.stop, + topP = body.findValue("topP", "top_p").asDouble() ?: current?.topP, + topK = body.findValue("topK", "top_k").asInt() ?: current?.topK, + repetitionPenalty = body.findValue("repetitionPenalty", "repetition_penalty").asDouble() + ?: current?.repetitionPenalty, ) } +private val RESERVED_BODY_KEYS = setOf( + "frequencyPenalty", "frequency_penalty", + "maxTokens", "max_tokens", + "messages", + "minP", "min_p", + "presencePenalty", "presence_penalty", + "repetitionPenalty", "repetition_penalty", + "stop", + "temperature", + "toolChoice", "tool_choice", + "tools", + "topK", "top_k", + "topP", "top_p", +) + +private fun Map.findValue(vararg keys: String): Any? { + keys.forEach { key -> + if (containsKey(key)) { + return this[key] + } + } + return null +} + +private fun Any?.asDouble(): Double? = when (this) { + is Number -> toDouble() + is String -> toDoubleOrNull() + else -> null +} + +private fun Any?.asInt(): Int? = when (this) { + is Int -> this + is Long -> takeIf { it in Int.MIN_VALUE..Int.MAX_VALUE }?.toInt() + is Number -> { + val value = toDouble() + if (!value.isFinite() || value % 1.0 != 0.0) { + null + } else { + value.toInt() + } + } + + is String -> toIntOrNull() + else -> null +} + +private fun Any?.asJsonElementMap(): Map? { + if (this !is Map<*, *>) return null + + return buildMap { + this@asJsonElementMap.forEach { (key, value) -> + key?.toString() + ?.takeIf { it.isNotBlank() } + ?.let { put(it, value.toJsonElement()) } + } + } +} + +private fun Any?.asString(): String? = when (this) { + is String -> this + else -> null +} + +private fun Any?.asStopList(): List? = asStringList(allowEmpty = false) + +private fun Any?.asStringList(allowEmpty: Boolean = false): List? = when (this) { + is String -> listOf(this) + is List<*> -> mapNotNull { item -> + when (item) { + is String -> item + null -> null + else -> item.toString() + } + }.takeIf { allowEmpty || it.isNotEmpty() } + + else -> null +} + +private fun Any?.asToolChoice(): ToolChoice? = when (this) { + is ToolChoice -> this + is String -> when (trim().lowercase()) { + "auto" -> ToolChoice.Auto + "none" -> ToolChoice.None + "required" -> ToolChoice.Required + else -> trim().takeIf { it.isNotEmpty() }?.let { ToolChoice.Named(it) } + } + + is Map<*, *> -> { + when (this["type"]?.toString()?.trim()?.lowercase()) { + "none" -> ToolChoice.None + "auto" -> ToolChoice.Auto + "required" -> ToolChoice.Required + else -> { + val directName = this["name"]?.toString() + val nestedName = (this["function"] as? Map<*, *>)?.get("name")?.toString() + (directName ?: nestedName) + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { ToolChoice.Named(it) } + } + } + } + + else -> null +} + +private fun Any?.toJsonElement(): JsonElement = when (this) { + null -> JsonNull + is JsonElement -> this + is String -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + is Boolean -> JsonPrimitive(this) + is Map<*, *> -> JsonObject( + entries.associate { (key, value) -> key.toString() to value.toJsonElement() } + ) + + is Iterable<*> -> JsonArray(map { it.toJsonElement() }) + is Array<*> -> JsonArray(map { it.toJsonElement() }) + else -> JsonPrimitive(toString()) +} + /** * CustomOpenAI chat-completions parameters layered on top of [LLMParams]. * @@ -125,7 +258,6 @@ public open class CustomOpenAIParams( public fun copy( temperature: Double? = this.temperature, maxTokens: Int? = this.maxTokens, - numberOfChoices: Int? = this.numberOfChoices, speculation: String? = this.speculation, schema: Schema? = this.schema, toolChoice: ToolChoice? = this.toolChoice, @@ -147,7 +279,6 @@ public open class CustomOpenAIParams( ): CustomOpenAIParams = CustomOpenAIParams( temperature = temperature, maxTokens = maxTokens, - numberOfChoices = numberOfChoices, speculation = speculation, schema = schema, toolChoice = toolChoice, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/ReadTool.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/ReadTool.kt index 1ae5e186..b8e20c84 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/ReadTool.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/ReadTool.kt @@ -6,8 +6,8 @@ import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.VirtualFileManager import ee.carlrobert.codegpt.settings.ProxyAISettingsService import ee.carlrobert.codegpt.settings.ToolPermissionPolicy import ee.carlrobert.codegpt.settings.hooks.HookEventType @@ -17,6 +17,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths import java.nio.charset.StandardCharsets /** @@ -113,6 +116,20 @@ class ReadTool( } return try { + val path = Paths.get(args.filePath).normalize() + if (!Files.exists(path)) { + return Result.Error( + filePath = args.filePath, + error = "File not found: ${args.filePath}" + ) + } + if (Files.isDirectory(path)) { + return Result.Error( + filePath = args.filePath, + error = "Path is a directory, not a file: ${args.filePath}" + ) + } + val result = withContext(Dispatchers.Default) { runReadAction { if (settingsService.isPathIgnored(args.filePath)) { @@ -122,23 +139,14 @@ class ReadTool( ) } - val virtualFile = - VirtualFileManager.getInstance() - .findFileByUrl("file://${args.filePath}") - ?: return@runReadAction Result.Error( - filePath = args.filePath, - error = "File not found: ${args.filePath}" - ) + val fileSystemPath = path.toString().replace('\\', '/') + val virtualFile = LocalFileSystem.getInstance().findFileByPath(fileSystemPath) - if (virtualFile.isDirectory) { - return@runReadAction Result.Error( - filePath = args.filePath, - error = "Path is a directory, not a file: ${args.filePath}" - ) + val fileType = when { + virtualFile != null -> FileTypeManager.getInstance().getFileTypeByFile(virtualFile) + else -> FileTypeManager.getInstance().getFileTypeByFileName(path.fileName.toString()) } - val fileType = FileTypeManager.getInstance().getFileTypeByFile(virtualFile) - when { fileType.isBinary -> { Result.Error( @@ -148,17 +156,7 @@ class ReadTool( } else -> { - val content = try { - val document = - FileDocumentManager.getInstance().getDocument(virtualFile) - document?.text ?: String( - virtualFile.contentsToByteArray(), - StandardCharsets.UTF_8 - ) - } catch (e: Exception) { - logger.error("Error reading document from $virtualFile", e) - String(virtualFile.contentsToByteArray(), StandardCharsets.UTF_8) - } + val content = readFileContent(path, virtualFile?.path) val lines = content.lines() val totalLines = lines.size @@ -269,4 +267,27 @@ class ReadTool( ("Error reading file '${result.filePath}': ${result.error}").truncateToolResult() } } + + private fun readFileContent(path: Path, virtualFilePath: String?): String { + if (virtualFilePath != null) { + val virtualFile = LocalFileSystem.getInstance().findFileByPath(virtualFilePath) + if (virtualFile != null) { + try { + val document = FileDocumentManager.getInstance().getDocument(virtualFile) + if (document != null) { + return document.text + } + return String(virtualFile.contentsToByteArray(), StandardCharsets.UTF_8) + } catch (e: Exception) { + logger.error("Error reading document from $virtualFile", e) + } + } + } + + return try { + Files.readString(path, StandardCharsets.UTF_8) + } catch (_: Exception) { + String(Files.readAllBytes(path), StandardCharsets.UTF_8) + } + } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt index 19bf8cea..f98bb618 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt @@ -11,6 +11,7 @@ import ee.carlrobert.codegpt.credentials.CredentialsStore.getCredential import ee.carlrobert.codegpt.mcp.McpToolConverter import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState +import ee.carlrobert.codegpt.settings.service.custom.CustomServicePlaceholders import ee.carlrobert.codegpt.settings.service.custom.CustomServicesSettings import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionMessage import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage @@ -112,43 +113,34 @@ class CustomOpenAIRequestFactory : BaseRequestFactory() { settings.headers.forEach { (key, value) -> val headerValue = when { - credential != null && value.contains("\$CUSTOM_SERVICE_API_KEY") -> - value.replace("\$CUSTOM_SERVICE_API_KEY", credential) + credential != null && value.contains($$"$CUSTOM_SERVICE_API_KEY") -> + value.replace($$"$CUSTOM_SERVICE_API_KEY", credential) else -> value } requestBuilder.addHeader(key, headerValue) } - val body = settings.body.toMutableMap().apply { - replaceAll { key, value -> - when { - !streamRequest && key == "stream" -> false - value is String && value.trim() == "\$OPENAI_MESSAGES" -> messages - else -> value - } + val body = settings.body + .mapValues { (key, value) -> + transformBodyValue( + key = key, + value = value, + streamRequest = streamRequest, + messages = messages, + credential = credential + ) } - - if (params != null && !params.mcpTools.isNullOrEmpty() && params.toolApprovalMode != ToolApprovalMode.BLOCK_ALL) { - val openAITools = params.mcpTools!!.map { mcpTool -> - McpToolConverter.convertToOpenAITool(mcpTool) - } - - put("tools", openAITools) - - when (params.toolApprovalMode) { - ToolApprovalMode.AUTO_APPROVE -> { - put("tool_choice", "auto") + .toMutableMap() + .apply { + if (params != null && !params.mcpTools.isNullOrEmpty() && params.toolApprovalMode != ToolApprovalMode.BLOCK_ALL) { + val openAITools = params.mcpTools!!.map { mcpTool -> + McpToolConverter.convertToOpenAITool(mcpTool) } - ToolApprovalMode.REQUIRE_APPROVAL -> { - put("tool_choice", "auto") - } - - else -> {} + put("tools", openAITools) } } - } return try { val requestBodyString = ObjectMapper().writerWithDefaultPrettyPrinter() @@ -162,5 +154,57 @@ class CustomOpenAIRequestFactory : BaseRequestFactory() { throw RuntimeException("Failed to build CustomOpenAI request", e) } } + + private fun transformBodyValue( + key: String?, + value: Any?, + streamRequest: Boolean, + messages: List, + credential: String? + ): Any? { + return when (value) { + null -> null + is String -> when { + !streamRequest && key == "stream" -> false + CustomServicePlaceholders.isMessages(value) -> messages + CustomServicePlaceholders.isPrompt(value) -> renderPrompt(messages) + credential != null && value.contains($$"$CUSTOM_SERVICE_API_KEY") -> + value.replace($$"$CUSTOM_SERVICE_API_KEY", credential) + + else -> value + } + + is Map<*, *> -> value.entries.associate { (nestedKey, nestedValue) -> + nestedKey.toString() to transformBodyValue( + key = nestedKey?.toString(), + value = nestedValue, + streamRequest = streamRequest, + messages = messages, + credential = credential + ) + } + + is List<*> -> value.map { item -> + transformBodyValue( + key = null, + value = item, + streamRequest = streamRequest, + messages = messages, + credential = credential + ) + } + + else -> value + } + } + + private fun renderPrompt(messages: List): String { + return messages.joinToString(separator = "\n\n") { message -> + when (message) { + is OpenAIChatCompletionStandardMessage -> message.content + else -> ObjectMapper().writeValueAsString(message) + } + } + } } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceBodyValueType.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceBodyValueType.kt new file mode 100644 index 00000000..fca3b6b8 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceBodyValueType.kt @@ -0,0 +1,28 @@ +package ee.carlrobert.codegpt.settings.service.custom + +enum class CustomServiceBodyValueType( + private val label: String, + val templateValue: String, +) { + STRING("String", ""), + PLACEHOLDER("Placeholder", $$"$OPENAI_MESSAGES"), + NUMBER("Number", "0"), + BOOLEAN("Boolean", "false"), + NULL("Null", ""), + OBJECT("Object", "{\n \"key\": \"value\"\n}"), + ARRAY("Array", "[\n \"value\"\n]"); + + override fun toString(): String = label + + companion object { + fun infer(value: Any?): CustomServiceBodyValueType = when (value) { + null -> NULL + is String -> if (CustomServicePlaceholders.containsCode(value.trim())) PLACEHOLDER else STRING + is Map<*, *> -> OBJECT + is List<*> -> ARRAY + is Boolean -> BOOLEAN + is Number -> NUMBER + else -> STRING + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceFormTabbedPane.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceFormTabbedPane.kt new file mode 100644 index 00000000..b9301089 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceFormTabbedPane.kt @@ -0,0 +1,310 @@ +package ee.carlrobert.codegpt.settings.service.custom + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import com.intellij.ui.ToolbarDecorator +import com.intellij.ui.components.JBTabbedPane +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.util.ApplicationUtil +import java.awt.Dimension +import javax.swing.JComponent +import javax.swing.table.DefaultTableModel + +class CustomServiceFormTabbedPane( + headers: Map, + body: Map, +) : JBTabbedPane() { + + private val editorProject: Project = resolveEditorProject() + private val jsonFileType: FileType = + FileTypeManager.getInstance().getFileTypeByExtension("json") + + private val headersTable = createHeadersTable(headers) + private val bodyTable = createBodyTable(body) + + var headers: MutableMap + get() = getHeadersData(headersTable).toMutableMap() + set(value) { + setHeadersData(headersTable, value) + } + + @Suppress("UNCHECKED_CAST") + var body: MutableMap + get() = getBodyData(bodyTable) as MutableMap + set(value) { + setBodyData(bodyTable, value) + } + + init { + tabComponentInsets = JBUI.insetsTop(8) + addTab("Headers", createHeadersGuidedPanel()) + addTab("Body", createBodyGuidedPanel()) + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + headersTable.isEnabled = enabled + bodyTable.isEnabled = enabled + } + + private fun createHeadersTable(headers: Map): JBTable { + val model = object : DefaultTableModel(HEADER_COLUMNS, 0) { + override fun isCellEditable(row: Int, column: Int): Boolean = false + } + + return JBTable(model).apply { + visibleRowCount = 6 + setHeadersData(this, headers) + } + } + + private fun createBodyTable(body: Map): JBTable { + val model = object : DefaultTableModel(BODY_COLUMNS, 0) { + override fun isCellEditable(row: Int, column: Int): Boolean = false + } + + return JBTable(model).apply { + visibleRowCount = 6 + setBodyData(this, body) + } + } + + private fun createHeadersGuidedPanel(): JComponent { + return ToolbarDecorator.createDecorator(headersTable) + .setPreferredSize(Dimension(0, JBUI.scale(360))) + .setAddAction { handleAddHeaderProperty() } + .setEditAction { handleEditHeaderProperty() } + .setRemoveAction { removeSelectedHeaderProperty() } + .addExtraAction(object : + DumbAwareAction("Edit JSON", "Edit JSON as raw text", jsonFileType.icon) { + override fun actionPerformed(event: AnActionEvent) { + openHeadersJsonDialog() + } + }) + .disableUpAction() + .disableDownAction() + .createPanel() + } + + private fun createBodyGuidedPanel(): JComponent { + return ToolbarDecorator.createDecorator(bodyTable) + .setPreferredSize(Dimension(0, JBUI.scale(360))) + .setAddAction { handleAddBodyProperty() } + .setEditAction { handleEditBodyProperty() } + .setRemoveAction { removeSelectedBodyProperty() } + .addExtraAction(object : + DumbAwareAction("Edit JSON", "Edit JSON as raw text", jsonFileType.icon) { + override fun actionPerformed(event: AnActionEvent) { + openBodyJsonDialog() + } + }) + .disableUpAction() + .disableDownAction() + .createPanel() + } + + private fun openHeadersJsonDialog() { + val dialog = CustomServiceJsonEditorDialog( + editorProject = editorProject, + jsonFileType = jsonFileType, + title = "Edit Headers JSON", + initialJson = CustomServiceJsonUtils.toPrettyJson(getHeadersData(headersTable)), + parser = CustomServiceJsonUtils::parseHeadersJson, + ) + if (dialog.showAndGet()) { + setHeadersData(headersTable, dialog.value) + } + } + + private fun openBodyJsonDialog() { + val dialog = CustomServiceJsonEditorDialog( + editorProject = editorProject, + jsonFileType = jsonFileType, + title = "Edit Body JSON", + initialJson = CustomServiceJsonUtils.toPrettyJson(getBodyData(bodyTable)), + parser = CustomServiceJsonUtils::parseBodyJson, + ) + if (dialog.showAndGet()) { + setBodyData(bodyTable, dialog.value) + } + } + + private fun setHeadersData(table: JBTable, headers: Map) { + val model = table.model as DefaultTableModel + if (getHeadersData(table) == headers) { + return + } + + model.rowCount = 0 + headers.forEach { (key, value) -> model.addRow(arrayOf(key, value)) } + } + + private fun getHeadersData(table: JBTable): Map { + val model = table.model as DefaultTableModel + return buildMap { + repeat(model.rowCount) { row -> + val key = model.getValueAt(row, 0).toString().trim() + if (key.isBlank()) { + return@repeat + } + put(key, model.getValueAt(row, 1)?.toString().orEmpty()) + } + } + } + + private fun setBodyData(table: JBTable, body: Map) { + val model = table.model as DefaultTableModel + if (getBodyData(table) == body) { + return + } + + model.rowCount = 0 + body.forEach { (key, value) -> + model.addRow(arrayOf(key, CustomServiceJsonUtils.toBodyDisplayValue(value))) + } + } + + private fun getBodyData(table: JBTable): MutableMap { + val model = table.model as DefaultTableModel + return buildMap { + repeat(model.rowCount) { row -> + val key = model.getValueAt(row, 0).toString().trim() + if (key.isBlank()) { + return@repeat + } + val rawValue = model.getValueAt(row, 1)?.toString().orEmpty() + put(key, CustomServiceJsonUtils.parseBodyValue(rawValue)) + } + }.toMutableMap() + } + + private fun handleAddHeaderProperty() { + val dialog = CustomServiceHeaderPropertyDialog(null, getExistingKeys(headersTable, null)) + if (!dialog.showAndGet()) return + addOrUpdateHeaderRow(-1, dialog.headerRow) + } + + private fun handleEditHeaderProperty() { + val selectedRow = headersTable.selectedRow + if (selectedRow < 0) return + + val existing = getHeaderRowFromTable(selectedRow) + val dialog = + CustomServiceHeaderPropertyDialog(existing, getExistingKeys(headersTable, existing.key)) + if (!dialog.showAndGet()) return + + addOrUpdateHeaderRow(selectedRow, dialog.headerRow) + } + + private fun removeSelectedHeaderProperty() { + val selectedRow = headersTable.selectedRow + if (selectedRow < 0) return + (headersTable.model as DefaultTableModel).removeRow(selectedRow) + } + + private fun getHeaderRowFromTable(rowIndex: Int): CustomServiceHeaderRow { + val model = headersTable.model as DefaultTableModel + return CustomServiceHeaderRow( + key = model.getValueAt(rowIndex, 0)?.toString().orEmpty(), + value = model.getValueAt(rowIndex, 1)?.toString().orEmpty(), + ) + } + + private fun addOrUpdateHeaderRow(rowIndex: Int, row: CustomServiceHeaderRow) { + val model = headersTable.model as DefaultTableModel + if (rowIndex in 0 until model.rowCount) { + model.setValueAt(row.key, rowIndex, 0) + model.setValueAt(row.value, rowIndex, 1) + return + } + + model.addRow(arrayOf(row.key, row.value)) + val newRow = model.rowCount - 1 + headersTable.selectionModel.setSelectionInterval(newRow, newRow) + } + + private fun handleAddBodyProperty() { + val dialog = CustomServiceBodyPropertyDialog(null, getExistingKeys(bodyTable, null)) + if (!dialog.showAndGet()) return + addOrUpdateBodyRow(-1, dialog.bodyRow) + } + + private fun handleEditBodyProperty() { + val selectedRow = bodyTable.selectedRow + if (selectedRow < 0) return + + val existing = getBodyRowFromTable(selectedRow) + val dialog = + CustomServiceBodyPropertyDialog(existing, getExistingKeys(bodyTable, existing.key)) + if (!dialog.showAndGet()) return + + addOrUpdateBodyRow(selectedRow, dialog.bodyRow) + } + + private fun removeSelectedBodyProperty() { + val selectedRow = bodyTable.selectedRow + if (selectedRow < 0) return + (bodyTable.model as DefaultTableModel).removeRow(selectedRow) + } + + private fun getBodyRowFromTable(rowIndex: Int): CustomServiceBodyRow { + val model = bodyTable.model as DefaultTableModel + val value = model.getValueAt(rowIndex, 1)?.toString().orEmpty() + return CustomServiceBodyRow( + key = model.getValueAt(rowIndex, 0)?.toString().orEmpty(), + type = CustomServiceBodyValueType.infer(CustomServiceJsonUtils.parseBodyValue(value)), + value = value, + ) + } + + private fun addOrUpdateBodyRow(rowIndex: Int, row: CustomServiceBodyRow) { + val model = bodyTable.model as DefaultTableModel + val normalizedValue = normalizeBodyValueForTable(row.type, row.value) + + if (rowIndex in 0 until model.rowCount) { + model.setValueAt(row.key, rowIndex, 0) + model.setValueAt(normalizedValue, rowIndex, 1) + return + } + + model.addRow(arrayOf(row.key, normalizedValue)) + val newRow = model.rowCount - 1 + bodyTable.selectionModel.setSelectionInterval(newRow, newRow) + } + + private fun normalizeBodyValueForTable( + type: CustomServiceBodyValueType, + value: String + ): String { + if (type == CustomServiceBodyValueType.NULL) return "null" + return value + } + + private fun getExistingKeys(table: JBTable, excludedKey: String?): Set { + val model = table.model as DefaultTableModel + return buildSet { + repeat(model.rowCount) { row -> + val key = model.getValueAt(row, 0)?.toString().orEmpty().trim() + if (key.isBlank() || key == excludedKey) { + return@repeat + } + add(key) + } + } + } + + private fun resolveEditorProject(): Project { + val project = ApplicationUtil.findCurrentProject() + return if (project != null && !project.isDisposed) project else ProjectManager.getInstance().defaultProject + } + + private companion object { + private val HEADER_COLUMNS = arrayOf("Key", "Value") + private val BODY_COLUMNS = arrayOf("Key", "Value") + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceJsonEditorDialog.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceJsonEditorDialog.kt new file mode 100644 index 00000000..db69da78 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceJsonEditorDialog.kt @@ -0,0 +1,229 @@ +package ee.carlrobert.codegpt.settings.service.custom + +import com.intellij.openapi.actionSystem.* +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.highlighter.EditorHighlighterFactory +import com.intellij.openapi.editor.markup.* +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.keymap.KeymapUtil +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.psi.PsiFileFactory +import com.intellij.psi.codeStyle.CodeStyleManager +import com.intellij.ui.EditorTextField +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.FormBuilder +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.JComponent + +class CustomServiceJsonEditorDialog( + private val editorProject: Project, + private val jsonFileType: FileType, + title: String, + initialJson: String, + private val parser: (String) -> T, +) : DialogWrapper(true) { + + private val jsonEditor = EditorTextField("", editorProject, jsonFileType) + private var parsedValue: T? = null + private var validationHighlighter: RangeHighlighter? = null + + init { + this.title = title + configureJsonEditor(::validateLive) + jsonEditor.text = initialJson + validateLive() + init() + initValidation() + } + + val value: T + get() = checkNotNull(parsedValue) + + override fun createCenterPanel(): JComponent { + return FormBuilder.createFormBuilder() + .addComponent(JBScrollPane(jsonEditor)) + .panel + .apply { + val dialogSize = Dimension(JBUI.scale(480), JBUI.scale(300)) + preferredSize = dialogSize + minimumSize = dialogSize + } + } + + override fun doValidate(): ValidationInfo? { + return parseValidationError(jsonEditor.text)?.let { ValidationInfo(it, jsonEditor) } + } + + private fun validateLive() { + val validationError = parseValidationError(jsonEditor.text) + if (validationError == null) { + clearValidationHighlight() + } else { + applyValidationHighlight(validationError) + } + } + + private fun parseValidationError(rawJson: String): String? { + return try { + parsedValue = parser(rawJson) + null + } catch (exception: IllegalArgumentException) { + exception.message?.takeIf { it.isNotBlank() } ?: "Malformed JSON." + } + } + + private fun configureJsonEditor(listener: () -> Unit) { + jsonEditor.apply { + setOneLineMode(false) + this.preferredSize = Dimension(JBUI.scale(300), JBUI.scale(120)) + + addSettingsProvider { editor -> + editor.settings.apply { + isLineNumbersShown = true + isRightMarginShown = false + isUseSoftWraps = false + isWhitespacesShown = false + isFoldingOutlineShown = true + isAllowSingleLogicalLineFolding = true + isAutoCodeFoldingEnabled = true + } + + (editor as EditorEx).apply { + highlighter = EditorHighlighterFactory.getInstance().createEditorHighlighter( + editorProject, + PsiFileFactory.getInstance(editorProject).createFileFromText( + "proxyai-custom-openai.json", + jsonFileType, + jsonEditor.text, + System.currentTimeMillis(), + true, + ).virtualFile, + ) + isViewer = false + backgroundColor = colorsScheme.defaultBackground + } + } + + this.document.addDocumentListener(object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + listener() + } + }) + + registerProjectReformatShortcut(listener) + } + } + + private fun registerProjectReformatShortcut(onReformat: () -> Unit) { + val shortcutSet = getReformatShortcutSet() ?: return + if (shortcutSet.shortcuts.isEmpty()) return + + object : DumbAwareAction() { + override fun actionPerformed(event: AnActionEvent) { + reformatJsonWithProjectStyle() + onReformat() + } + }.registerCustomShortcutSet(shortcutSet, jsonEditor) + } + + private fun getReformatShortcutSet(): ShortcutSet? { + val shortcuts = linkedSetOf() + shortcuts += KeymapUtil.getActiveKeymapShortcuts(IdeActions.ACTION_EDITOR_REFORMAT).shortcuts + shortcuts += KeymapUtil.getActiveKeymapShortcuts("ReformatCode").shortcuts + if (shortcuts.isEmpty()) return null + return CustomShortcutSet(*shortcuts.toTypedArray()) + } + + private fun reformatJsonWithProjectStyle() { + try { + val psiFile = PsiFileFactory.getInstance(editorProject).createFileFromText( + "proxyai-custom-openai.json", + jsonFileType, + jsonEditor.text, + System.currentTimeMillis(), + true, + ) + + WriteCommandAction.runWriteCommandAction(editorProject) { + CodeStyleManager.getInstance(editorProject).reformat(psiFile) + } + + jsonEditor.text = psiFile.text + } catch (_: Exception) { + } + } + + private fun applyValidationHighlight(message: String) { + val editor = jsonEditor.editor as? EditorEx ?: return + clearValidationHighlight() + + val text = jsonEditor.text + if (text.isEmpty()) return + + val range = resolveErrorRange(text, message) + validationHighlighter = editor.markupModel.addRangeHighlighter( + range.first, + range.second, + HighlighterLayer.ERROR + 1, + TextAttributes().apply { + effectColor = JBColor.RED + effectType = EffectType.WAVE_UNDERSCORE + }, + HighlighterTargetArea.EXACT_RANGE, + ).apply { + errorStripeTooltip = message + } + } + + private fun clearValidationHighlight() { + val editor = jsonEditor.editor as? EditorEx ?: return + validationHighlighter?.let(editor.markupModel::removeHighlighter) + validationHighlighter = null + } + + private fun resolveErrorRange(text: String, message: String): Pair { + val match = LINE_COLUMN_PATTERN.find(message) ?: return 0 to minOf(text.length, 1) + + val line = match.groupValues[1].toIntOrNull() ?: return 0 to minOf(text.length, 1) + val column = match.groupValues[2].toIntOrNull() ?: return 0 to minOf(text.length, 1) + val offset = lineColumnToOffset(text, line, column) + val lineEnd = text.indexOf('\n', offset).let { if (it < 0) text.length else it } + val highlightEnd = when { + lineEnd > offset -> lineEnd + offset < text.length -> offset + 1 + else -> text.length + } + return offset to highlightEnd + } + + private fun lineColumnToOffset(text: String, line: Int, column: Int): Int { + if (line <= 1) { + return (column - 1).coerceIn(0, text.length) + } + + var currentLine = 1 + var offset = 0 + while (currentLine < line && offset < text.length) { + val nextNewline = text.indexOf('\n', offset) + if (nextNewline < 0) { + return text.length + } + offset = nextNewline + 1 + currentLine++ + } + + return (offset + column - 1).coerceIn(0, text.length) + } + + companion object { + private val LINE_COLUMN_PATTERN = Regex("""line:\s*(\d+),\s*column:\s*(\d+)""") + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceJsonUtils.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceJsonUtils.kt new file mode 100644 index 00000000..52b97668 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceJsonUtils.kt @@ -0,0 +1,205 @@ +package ee.carlrobert.codegpt.settings.service.custom + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper + +object CustomServiceJsonUtils { + + private val objectMapper = ObjectMapper() + + fun toPrettyJson(value: Map<*, *>): String = try { + objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value) + } catch (_: Exception) { + "{}" + } + + fun parseHeadersJson(rawJson: String): Map { + if (rawJson.isBlank()) { + return emptyMap() + } + + return try { + val parsed = + objectMapper.readValue(rawJson, object : TypeReference>() {}) + buildMap { + parsed.forEach { (rawKey, rawValue) -> + val key = rawKey.trim() + if (key.isEmpty()) { + throw IllegalArgumentException("Header key cannot be empty.") + } + if (rawValue == null) { + throw IllegalArgumentException("Header '$key' cannot be null.") + } + if (rawValue is Map<*, *> || rawValue is List<*>) { + throw IllegalArgumentException("Header '$key' must be string/number/boolean.") + } + put(key, rawValue.toString()) + } + } + } catch (exception: IllegalArgumentException) { + throw exception + } catch (exception: Exception) { + throw IllegalArgumentException(summarizeParseError(exception)) + } + } + + fun parseBodyJson(rawJson: String): Map { + if (rawJson.isBlank()) { + return emptyMap() + } + + return try { + objectMapper.readValue(rawJson, object : TypeReference>() {}) + } catch (exception: IllegalArgumentException) { + throw exception + } catch (exception: Exception) { + throw IllegalArgumentException(summarizeParseError(exception)) + } + } + + fun toBodyDisplayValue(value: Any?): String = when (value) { + null -> "" + is Map<*, *>, is List<*> -> try { + objectMapper.writeValueAsString(value) + } catch (_: Exception) { + value.toString() + } + + else -> value.toString() + } + + fun parseBodyValue(type: CustomServiceBodyValueType, value: String): Any? { + val trimmed = value.trim() + return when (type) { + CustomServiceBodyValueType.STRING -> value + CustomServiceBodyValueType.PLACEHOLDER -> parsePlaceholderValue(trimmed) + CustomServiceBodyValueType.NUMBER -> parseBodyNumber(trimmed) + CustomServiceBodyValueType.BOOLEAN -> parseBoolean(trimmed) + CustomServiceBodyValueType.NULL -> null + CustomServiceBodyValueType.OBJECT -> parseJsonObject(trimmed) + CustomServiceBodyValueType.ARRAY -> parseJsonArray(trimmed) + } + } + + fun parseBodyValue(value: String): Any? { + val trimmed = value.trim() + if (trimmed.isEmpty()) { + return "" + } + + if (trimmed.equals("null", ignoreCase = true)) { + return null + } + if (trimmed.equals("true", ignoreCase = true) || trimmed.equals( + "false", + ignoreCase = true + ) + ) { + return trimmed.toBooleanStrictOrNull() ?: value + } + + try { + return if ('.' in trimmed || 'e' in trimmed || 'E' in trimmed) { + trimmed.toDouble() + } else { + trimmed.toIntOrNull() ?: trimmed.toLong() + } + } catch (_: NumberFormatException) { + } + + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return try { + parseJsonObject(trimmed) + } catch (_: IllegalArgumentException) { + value + } + } + + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return try { + parseJsonArray(trimmed) + } catch (_: IllegalArgumentException) { + value + } + } + + return value + } + + private fun summarizeParseError(exception: Exception): String { + val message = exception.message + if (message.isNullOrBlank()) { + return "Malformed JSON." + } + val newlineIndex = message.indexOf('\n') + return if (newlineIndex > 0) message.substring(0, newlineIndex) else message + } + + private fun parseBodyNumber(value: String): Number { + if (value.isEmpty()) { + throw IllegalArgumentException("Number value cannot be empty.") + } + + return try { + if ('.' in value || 'e' in value || 'E' in value) { + value.toDouble() + } else { + value.toIntOrNull() ?: value.toLong() + } + } catch (_: NumberFormatException) { + throw IllegalArgumentException("Invalid number format.") + } + } + + private fun parsePlaceholderValue(value: String): String { + if (value.isEmpty()) { + throw IllegalArgumentException("Placeholder is required.") + } + if (!CustomServicePlaceholders.containsCode(value)) { + throw IllegalArgumentException("Unknown placeholder value.") + } + return value.trim() + } + + private fun parseBoolean(value: String): Boolean = when { + value.equals("true", ignoreCase = true) -> true + value.equals("false", ignoreCase = true) -> false + else -> throw IllegalArgumentException("Boolean must be 'true' or 'false'.") + } + + private fun parseJsonObject(value: String): Any { + if (value.isBlank()) { + throw IllegalArgumentException("Object value cannot be empty.") + } + + return try { + val parsed = objectMapper.readValue(value, Any::class.java) + if (parsed !is Map<*, *>) { + throw IllegalArgumentException("Value must be a JSON object.") + } + parsed + } catch (exception: IllegalArgumentException) { + throw exception + } catch (_: Exception) { + throw IllegalArgumentException("Invalid JSON object.") + } + } + + private fun parseJsonArray(value: String): Any { + if (value.isBlank()) { + throw IllegalArgumentException("Array value cannot be empty.") + } + + return try { + val parsed = objectMapper.readValue(value, Any::class.java) + if (parsed !is List<*>) { + throw IllegalArgumentException("Value must be a JSON array.") + } + parsed + } catch (exception: IllegalArgumentException) { + throw exception + } catch (_: Exception) { + throw IllegalArgumentException("Invalid JSON array.") + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServicePlaceholders.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServicePlaceholders.kt new file mode 100644 index 00000000..9b01efb4 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServicePlaceholders.kt @@ -0,0 +1,42 @@ +package ee.carlrobert.codegpt.settings.service.custom + +data class CustomServicePlaceholder( + val name: String, + val code: String, + val description: String, +) { + override fun toString(): String = code +} + +object CustomServicePlaceholders { + + private const val PROMPT = $$"$PROMPT" + private const val MESSAGES = $$"$OPENAI_MESSAGES" + + val all: List = listOf( + CustomServicePlaceholder( + name = "PROMPT", + code = PROMPT, + description = "Replaces the placeholder with concatenated message content.", + ), + CustomServicePlaceholder( + name = "MESSAGES", + code = MESSAGES, + description = "Replaces the placeholder with structured OpenAI format messages as a JSON array.", + ), + ) + + private val canonicalCodes: Set = all.mapTo(linkedSetOf()) { it.code } + private val byCode: Map = all.associateBy { it.code } + + fun containsCode(value: String): Boolean = value.trim() in canonicalCodes + + fun findByCode(value: String?): CustomServicePlaceholder? { + val normalized = value?.trim() ?: return null + return byCode[normalized] + } + + fun isPrompt(value: String): Boolean = value.trim() == PROMPT + + fun isMessages(value: String): Boolean = value.trim() == MESSAGES +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServicePropertyDialogs.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServicePropertyDialogs.kt new file mode 100644 index 00000000..f6f5b2b2 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServicePropertyDialogs.kt @@ -0,0 +1,543 @@ +package ee.carlrobert.codegpt.settings.service.custom + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.highlighter.EditorHighlighterFactory +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.psi.PsiFileFactory +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.LabelPosition +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.layout.ComponentPredicate +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import ee.carlrobert.codegpt.util.ApplicationUtil +import java.awt.Dimension +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.swing.JComponent +import javax.swing.JSpinner +import javax.swing.SpinnerNumberModel + +class CustomServiceHeaderPropertyDialog( + existingRow: CustomServiceHeaderRow?, + private val existingKeys: Set, +) : DialogWrapper(true) { + + private val keyField = JBTextField() + private val valueField = JBTextField() + + init { + title = if (existingRow == null) "Add Header" else "Edit Header" + if (existingRow != null) { + keyField.text = existingRow.key + valueField.text = existingRow.value + } + init() + } + + override fun createCenterPanel(): JComponent { + return panel { + row("Key:") { + cell(keyField) + .validationOnInput { + val key = it.text.trim() + if (key.isEmpty()) { + error("Key is required") + } else if (key in existingKeys) { + error("A header with this key already exists") + } else { + isOKActionEnabled = true + null + } + } + .resizableColumn() + .align(AlignX.FILL) + } + row("Value:") { + cell(valueField) + .validationOnInput { + isOKActionEnabled = true + null + } + .resizableColumn() + .align(AlignX.FILL) + } + } + } + + override fun doValidate(): ValidationInfo? { + val key = keyField.text.trim() + if (key.isEmpty()) { + return ValidationInfo("Key is required.", keyField) + } + if (key in existingKeys) { + return ValidationInfo("A header with this key already exists.", keyField) + } + return null + } + + val headerRow: CustomServiceHeaderRow + get() = CustomServiceHeaderRow(keyField.text.trim(), valueField.text) +} + +class CustomServiceBodyPropertyDialog( + existingRow: CustomServiceBodyRow?, + private val existingKeys: Set, +) : DialogWrapper(true) { + + private val keyField = JBTextField() + private val typeComboBox = ComboBox(CustomServiceBodyValueType.entries.toTypedArray()) + + private val stringValueField by lazy { JBTextField() } + private val numberSpinner by lazy { + JSpinner(SpinnerNumberModel(0.0, null, null, 0.1)) + } + private val booleanComboBox by lazy { ComboBox(arrayOf("true", "false")) } + private val placeholderComboBox by lazy { + ComboBox(CustomServicePlaceholders.all.toTypedArray()) + } + + @Suppress("UsePropertyAccessSyntax") + private val jsonEditor by lazy { + EditorTextField("", resolveEditorProject(), resolveJsonFileType()).apply { + setOneLineMode(false) + preferredSize = Dimension(JBUI.scale(320), JBUI.scale(120)) + addSettingsProvider { editor -> + editor.settings.apply { + isLineNumbersShown = true + isRightMarginShown = false + isUseSoftWraps = false + isWhitespacesShown = false + isFoldingOutlineShown = true + isAllowSingleLogicalLineFolding = true + isAutoCodeFoldingEnabled = true + } + + (editor as EditorEx).apply { + highlighter = EditorHighlighterFactory.getInstance().createEditorHighlighter( + resolveEditorProject(), + PsiFileFactory.getInstance(resolveEditorProject()).createFileFromText( + "proxyai-custom-openai.json", + resolveJsonFileType(), + text, + System.currentTimeMillis(), + true, + ).virtualFile, + ) + backgroundColor = colorsScheme.defaultBackground + } + } + } + } + + private val isJsonSelected = selectedTypePredicate { it?.jsonType == true } + private val isStringSelected = selectedTypePredicate { it == CustomServiceBodyValueType.STRING } + private val isPlaceholderSelected = + selectedTypePredicate { it == CustomServiceBodyValueType.PLACEHOLDER } + private val isNumberSelected = selectedTypePredicate { it == CustomServiceBodyValueType.NUMBER } + private val isBooleanSelected = + selectedTypePredicate { it == CustomServiceBodyValueType.BOOLEAN } + private val isNullSelected = selectedTypePredicate { it == CustomServiceBodyValueType.NULL } + + private fun selectedTypePredicate( + predicate: (CustomServiceBodyValueType?) -> Boolean + ): ComponentPredicate = object : ComponentPredicate() { + override fun addListener(listener: (Boolean) -> Unit) { + typeComboBox.addItemListener { listener(invoke()) } + } + + override fun invoke(): Boolean = predicate(selectedType) + } + + var lastSelectedType: CustomServiceBodyValueType = CustomServiceBodyValueType.STRING + private set + + private val placeholderDescriptionLabel by lazy { + JBLabel().apply { foreground = UIUtil.getContextHelpForeground() } + } + + init { + title = if (existingRow == null) "Add Body Property" else "Edit Body Property" + + existingRow?.let { row -> + keyField.text = row.key + typeComboBox.selectedItem = row.type + populateValueForType(row.type, row.value) + } + + if (existingRow == null) { + typeComboBox.selectedItem = CustomServiceBodyValueType.STRING + stringValueField.text = CustomServiceBodyValueType.STRING.templateValue + numberSpinner.value = + CustomServiceBodyValueType.NUMBER.templateValue.toDoubleOrNull() ?: 0.0 + booleanComboBox.selectedItem = CustomServiceBodyValueType.BOOLEAN.templateValue + placeholderComboBox.selectedItem = CustomServicePlaceholders.all.firstOrNull() + } + + typeComboBox.addItemListener { onTypeChanged() } + placeholderComboBox.addItemListener { updatePlaceholderDescription() } + updatePlaceholderDescription() + + onTypeChanged(forceTemplateUpdate = existingRow == null) + + init() + } + + override fun createCenterPanel(): JComponent { + return panel { + row("Key:") { + cell(keyField) + .validationOnInput { + val key = it.text.trim() + if (key.isEmpty()) { + error("Key is required") + } else if (key in existingKeys) { + error("A property with this key already exists") + } else { + isOKActionEnabled = true + null + } + } + .resizableColumn() + .align(AlignX.FILL) + } + + row("Type:") { + cell(typeComboBox) + .validationRequestor { callback -> + typeComboBox.addItemListener { callback() } + } + .onChanged { + isOKActionEnabled = true + } + .validationOnInput { + if (selectedType == null) error("Type is required") + else null + } + .resizableColumn() + .align(AlignX.FILL) + } + + row { + scrollCell(jsonEditor) + .validationRequestor { callback -> + jsonEditor.document.addDocumentListener(object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + runInEdt { + callback.invoke() + } + } + }) + } + .validationOnInput { + selectedType?.let { type -> + if (!type.jsonType) return@let null + val text = jsonEditor.text + if (text.isBlank()) { + error("Value is required") + } else { + try { + CustomServiceJsonUtils.parseBodyValue(type, text) + isOKActionEnabled = true + null + } catch (e: IllegalArgumentException) { + error(e.message?.takeIf { it.isNotBlank() } ?: "Invalid JSON") + } + } + } + } + .label("Value:", LabelPosition.TOP) + .align(Align.FILL) + .resizableColumn() + }.resizableRow().visibleIf(isJsonSelected) + + row("Value:") { + cell(stringValueField) + .validationOnInput { + if (selectedType == CustomServiceBodyValueType.STRING && it.text.isBlank()) { + error("Value is required") + } else { + isOKActionEnabled = true + null + } + } + .resizableColumn() + .align(AlignX.FILL) + }.visibleIf(isStringSelected) + + row("Value:") { + cell(placeholderComboBox) + .validationRequestor { callback -> + placeholderComboBox.addItemListener { callback() } + } + .validationOnInput { + if (selectedType == CustomServiceBodyValueType.PLACEHOLDER) { + val placeholder = (it.selectedItem as? CustomServicePlaceholder)?.code + if (placeholder.isNullOrBlank()) { + error("Placeholder is required") + } else { + null + } + } else { + null + } + } + .resizableColumn() + .align(AlignX.FILL) + }.visibleIf(isPlaceholderSelected) + + row("Value:") { + val spinnerTextField = (numberSpinner.editor as? JSpinner.DefaultEditor)?.textField + cell(numberSpinner) + .validationRequestor { callback -> + spinnerTextField?.addKeyListener(object : KeyAdapter() { + override fun keyReleased(e: KeyEvent) { + runInEdt { + callback.invoke() + } + } + }) + } + .validationOnInput { spinner -> + val type = selectedType + if (type != CustomServiceBodyValueType.NUMBER) { + return@validationOnInput null + } + + try { + val text = spinnerTextField?.text?.trim().orEmpty() + CustomServiceJsonUtils.parseBodyValue( + type, + text.ifEmpty { spinner.value.toString() } + ) + null + } catch (e: IllegalArgumentException) { + error(e.message?.takeIf { it.isNotBlank() } ?: "Invalid number") + } + } + .resizableColumn() + }.visibleIf(isNumberSelected) + + row("Value:") { + cell(booleanComboBox) + .validationRequestor { callback -> + booleanComboBox.addItemListener { callback() } + } + .validationOnInput { comboBox -> + val type = selectedType + if (type != CustomServiceBodyValueType.BOOLEAN) { + return@validationOnInput null + } + + try { + CustomServiceJsonUtils.parseBodyValue( + type, + comboBox.selectedItem?.toString().orEmpty() + ) + null + } catch (e: IllegalArgumentException) { + error(e.message?.takeIf { it.isNotBlank() } ?: "Invalid boolean") + } + } + .resizableColumn() + }.visibleIf(isBooleanSelected) + + row { + cell(placeholderDescriptionLabel) + }.visibleIf(object : ComponentPredicate() { + override fun addListener(listener: (Boolean) -> Unit) { + typeComboBox.addItemListener { listener(invoke()) } + placeholderComboBox.addItemListener { listener(invoke()) } + } + + override fun invoke(): Boolean = + selectedType == CustomServiceBodyValueType.PLACEHOLDER && + placeholderDescriptionLabel.text.isNotEmpty() + }) + + row { + cell(JBLabel("Value is ignored when type is Null.").apply { + foreground = UIUtil.getContextHelpForeground() + }) + }.visibleIf(isNullSelected) + }.apply { + val dialogSize = Dimension(JBUI.scale(360), JBUI.scale(240)) + preferredSize = dialogSize + minimumSize = dialogSize + } + } + + override fun doValidate(): ValidationInfo? { + val key = keyField.text.trim() + if (key.isEmpty()) { + return ValidationInfo("Key is required.", keyField) + } + if (key in existingKeys) { + return ValidationInfo("A property with this key already exists.", keyField) + } + + val type = selectedType ?: return ValidationInfo("Type is required.", typeComboBox) + + val value = getValueForType(type) + if (value.isNullOrBlank()) { + return ValidationInfo("Value is required.", getValueComponent(type)) + } + + return try { + CustomServiceJsonUtils.parseBodyValue(type, value) + null + } catch (exception: IllegalArgumentException) { + ValidationInfo( + exception.message?.takeIf { it.isNotBlank() } ?: "Invalid value.", + getValueComponent(type) + ) + } + } + + val bodyRow: CustomServiceBodyRow + get() { + val type = selectedType ?: CustomServiceBodyValueType.STRING + val value = getValueForType(type).orEmpty() + return CustomServiceBodyRow( + key = keyField.text.trim(), + type = type, + value = value, + ) + } + + private val selectedType: CustomServiceBodyValueType? + get() = typeComboBox.selectedItem as? CustomServiceBodyValueType + + private val CustomServiceBodyValueType.jsonType: Boolean + get() = this == CustomServiceBodyValueType.OBJECT || this == CustomServiceBodyValueType.ARRAY + + private fun onTypeChanged(forceTemplateUpdate: Boolean = false) { + val newType = selectedType ?: return + + if (forceTemplateUpdate || shouldApplyTemplateValue(newType)) { + applyTemplateValueForType(newType) + } + + lastSelectedType = newType + } + + private fun shouldApplyTemplateValue(newType: CustomServiceBodyValueType): Boolean { + if (newType == CustomServiceBodyValueType.NULL) return false + if (newType == CustomServiceBodyValueType.PLACEHOLDER) return false + + val currentValue = getValueForType(newType) + if (currentValue.isNullOrBlank()) return true + if (currentValue == lastSelectedType.templateValue) return true + + if (newType.jsonType && lastSelectedType.jsonType && newType != lastSelectedType) { + return true + } + + return false + } + + private fun applyTemplateValueForType(type: CustomServiceBodyValueType) { + when (type) { + CustomServiceBodyValueType.STRING -> { + stringValueField.text = type.templateValue + stringValueField.caretPosition = stringValueField.text.length + } + + CustomServiceBodyValueType.NUMBER -> { + numberSpinner.value = type.templateValue.toDoubleOrNull() ?: 0.0 + } + + CustomServiceBodyValueType.BOOLEAN -> { + booleanComboBox.selectedItem = type.templateValue + } + + CustomServiceBodyValueType.OBJECT, CustomServiceBodyValueType.ARRAY -> { + jsonEditor.text = type.templateValue + } + + CustomServiceBodyValueType.NULL, + CustomServiceBodyValueType.PLACEHOLDER -> Unit + } + } + + private fun updatePlaceholderDescription() { + placeholderDescriptionLabel.text = + (placeholderComboBox.selectedItem as? CustomServicePlaceholder) + ?.description.orEmpty() + } + + private fun populateValueForType(type: CustomServiceBodyValueType, value: String) { + when (type) { + CustomServiceBodyValueType.STRING -> stringValueField.text = value + CustomServiceBodyValueType.NUMBER -> { + value.toDoubleOrNull()?.let { numberSpinner.value = it } + (numberSpinner.editor as? JSpinner.DefaultEditor)?.textField?.text = value + } + + CustomServiceBodyValueType.BOOLEAN -> { + booleanComboBox.selectedItem = + if (value.equals("true", ignoreCase = true)) "true" else "false" + } + + CustomServiceBodyValueType.PLACEHOLDER -> { + placeholderComboBox.selectedItem = CustomServicePlaceholders.findByCode(value) + ?: CustomServicePlaceholders.all.firstOrNull() + } + + CustomServiceBodyValueType.OBJECT, CustomServiceBodyValueType.ARRAY -> { + jsonEditor.text = value + } + + CustomServiceBodyValueType.NULL -> Unit + } + } + + private fun getValueForType(type: CustomServiceBodyValueType): String? { + return when (type) { + CustomServiceBodyValueType.STRING -> stringValueField.text + CustomServiceBodyValueType.NUMBER -> getNumberValueText() + CustomServiceBodyValueType.BOOLEAN -> booleanComboBox.selectedItem?.toString() + CustomServiceBodyValueType.PLACEHOLDER -> + (placeholderComboBox.selectedItem as? CustomServicePlaceholder)?.code + + CustomServiceBodyValueType.OBJECT, CustomServiceBodyValueType.ARRAY -> jsonEditor.text + CustomServiceBodyValueType.NULL -> "" + } + } + + private fun getValueComponent(type: CustomServiceBodyValueType): JComponent { + return when (type) { + CustomServiceBodyValueType.STRING -> stringValueField + CustomServiceBodyValueType.NUMBER -> numberSpinner + CustomServiceBodyValueType.BOOLEAN -> booleanComboBox + CustomServiceBodyValueType.PLACEHOLDER -> placeholderComboBox + CustomServiceBodyValueType.OBJECT, CustomServiceBodyValueType.ARRAY -> jsonEditor + CustomServiceBodyValueType.NULL -> typeComboBox + } + } + + private fun getNumberValueText(): String { + val textField = (numberSpinner.editor as? JSpinner.DefaultEditor)?.textField + val text = textField?.text?.trim().orEmpty() + return text.ifEmpty { numberSpinner.value.toString() } + } + + private fun resolveEditorProject(): Project { + val project = ApplicationUtil.findCurrentProject() + return if (project != null && !project.isDisposed) project else ProjectManager.getInstance().defaultProject + } + + private fun resolveJsonFileType() = FileTypeManager.getInstance().getFileTypeByExtension("json") +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceRows.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceRows.kt new file mode 100644 index 00000000..70f93d57 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServiceRows.kt @@ -0,0 +1,12 @@ +package ee.carlrobert.codegpt.settings.service.custom + +data class CustomServiceHeaderRow( + val key: String, + val value: String, +) + +data class CustomServiceBodyRow( + val key: String, + val type: CustomServiceBodyValueType, + val value: String, +) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServicesChangeNotifier.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServicesChangeNotifier.kt deleted file mode 100644 index 43423721..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/CustomServicesChangeNotifier.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ee.carlrobert.codegpt.settings.service.custom - -import com.intellij.openapi.diagnostic.thisLogger - -object CustomServicesChangeNotifier { - private val logger = thisLogger() - private val listeners = mutableListOf<() -> Unit>() - - fun addListener(listener: () -> Unit) { - listeners.add(listener) - } - - fun removeListener(listener: () -> Unit) { - listeners.remove(listener) - } - - fun notifyServicesChanged() { - listeners.forEach { listener -> - try { - listener() - } catch (e: Exception) { - logger.error("Error notifying listener", e) - } - } - } -} \ No newline at end of file diff --git a/src/test/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactoryTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactoryTest.kt new file mode 100644 index 00000000..dab3052f --- /dev/null +++ b/src/test/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactoryTest.kt @@ -0,0 +1,164 @@ +package ee.carlrobert.codegpt.completions.factory + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState +import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionMessage +import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage +import okhttp3.Request +import okio.Buffer +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.tuple +import org.junit.Test +import java.util.function.Function + +class CustomOpenAIRequestFactoryTest { + + @Test + fun `should transform nested placeholder values for chat request body`() { + val settings = CustomServiceChatCompletionSettingsState().apply { + url = "https://example.com/v1/chat/completions" + headers = mutableMapOf("Authorization" to $$"Bearer $CUSTOM_SERVICE_API_KEY") + body = mutableMapOf( + "model" to "gpt-test", + "payload" to mapOf( + "prompt_alias" to $$"$PROMPT", + "messages_alias" to $$"$OPENAI_MESSAGES", + "items" to listOf( + mapOf("kind" to "prompt", "value" to $$"$PROMPT"), + mapOf("kind" to "messages", "value" to $$"$OPENAI_MESSAGES") + ) + ) + ) + } + val messages = listOf( + OpenAIChatCompletionStandardMessage("system", "System instructions"), + OpenAIChatCompletionStandardMessage("user", "Write a test"), + ) + + val request = CustomOpenAIRequestFactory.buildCustomOpenAIChatCompletionRequest( + settings = settings, + messages = messages, + streamRequest = true, + credential = "secret-key", + ) + val body = request.bodyAsJson() + val payload = body["payload"] + val aliasedMessages = payload["messages_alias"].elements().asSequence().toList() + val payloadItems = payload["items"].elements().asSequence().toList() + val nestedMessageItemValues = payloadItems[1]["value"].elements().asSequence().toList() + + assertThat(request.header("Authorization")).isEqualTo("Bearer secret-key") + assertThat(payload["prompt_alias"].asText()) + .isEqualTo("System instructions\n\nWrite a test") + assertThat(aliasedMessages) + .extracting({ it["role"].asText() }, { it["content"].asText() }) + .containsExactly( + tuple("system", "System instructions"), + tuple("user", "Write a test") + ) + assertThat(payloadItems) + .extracting( + { it["kind"].asText() }, + { it["value"].isArray }, + { it["value"].isTextual } + ) + .containsExactly( + tuple("prompt", false, true), + tuple("messages", true, false) + ) + assertThat(payloadItems[0]["value"].asText()) + .isEqualTo("System instructions\n\nWrite a test") + assertThat(nestedMessageItemValues) + .extracting(Function { it["role"].asText() }) + .containsExactly("system", "user") + } + + @Test + fun `should preserve nested arrays and objects in chat request body`() { + val settings = CustomServiceChatCompletionSettingsState().apply { + url = "https://example.com/v1/chat/completions" + headers = mutableMapOf("X-Test" to "true") + body = mutableMapOf( + "model" to "gpt-test", + "config" to mapOf( + "temperature" to 0.2, + "enabled" to true, + "tags" to listOf("alpha", 2, false), + "nested" to mapOf("arr" to listOf(mapOf("k" to "v"))) + ) + ) + } + val messages = listOf( + OpenAIChatCompletionStandardMessage("user", "hello") + ) + + val request = CustomOpenAIRequestFactory.buildCustomOpenAIChatCompletionRequest( + settings = settings, + messages = messages, + streamRequest = true, + credential = null, + ) + val body = request.bodyAsJson() + val tags = body["config"]["tags"].elements().asSequence().toList() + val nestedArray = body["config"]["nested"]["arr"].elements().asSequence().toList() + + assertThat(body["config"]["temperature"].asDouble()).isEqualTo(0.2) + assertThat(body["config"]["enabled"].asBoolean()).isTrue() + assertThat(tags) + .extracting({ it.nodeType.name }, { it.asText() }) + .containsExactly( + tuple("STRING", "alpha"), + tuple("NUMBER", "2"), + tuple("BOOLEAN", "false") + ) + assertThat(nestedArray) + .extracting(Function { it["k"].asText() }) + .containsExactly("v") + } + + @Test + fun `should replace nested stream flags and nested api key placeholders when stream is disabled`() { + val settings = CustomServiceChatCompletionSettingsState().apply { + url = "https://example.com/v1/chat/completions" + headers = mutableMapOf("X-Test" to "true") + body = mutableMapOf( + "model" to "gpt-test", + "stream" to "true", + "config" to mapOf( + "stream" to "true", + "authorization" to $$"Bearer $CUSTOM_SERVICE_API_KEY" + ), + "items" to listOf( + mapOf("stream" to "true"), + $$"token:$CUSTOM_SERVICE_API_KEY" + ) + ) + } + val messages = listOf( + OpenAIChatCompletionStandardMessage("user", "hello") + ) + + val request = CustomOpenAIRequestFactory.buildCustomOpenAIChatCompletionRequest( + settings = settings, + messages = messages, + streamRequest = false, + credential = "secret-key", + ) + val body = request.bodyAsJson() + + assertThat(listOf(body["stream"], body["config"]["stream"], body["items"][0]["stream"])) + .extracting(Function { it.asBoolean() }) + .containsExactly(false, false, false) + assertThat(listOf(body["config"]["authorization"], body["items"][1])) + .extracting(Function { it.asText() }) + .containsExactly("Bearer secret-key", "token:secret-key") + } + + private fun Request.bodyAsJson(): JsonNode { + val requestBody = requireNotNull(body) { "Request body is null" } + val buffer = Buffer() + requestBody.writeTo(buffer) + return jacksonObjectMapper().readTree(buffer.readUtf8()) + } +}