feat: custom openai nested request params
Some checks failed
Build / Build (push) Has been cancelled
Build / Verify Plugin (push) Has been cancelled

This commit is contained in:
Carl-Robert Linnupuu 2026-02-09 23:33:24 +00:00
parent 6a7107c6e7
commit 0442d85dde
15 changed files with 1918 additions and 284 deletions

View file

@ -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<String, String> headers, Map<String, ?> 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<String, String> headers) {
setTableData(headersTable, headers);
}
public Map<String, String> 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<String, Object> body) {
setTableData(bodyTable, body);
}
public Map<String, Object> getBody() {
return getTableData(bodyTable);
}
private void setTableData(JBTable table, Map<String, ?> 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<String, ?> 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<String, Object> getTableData(JBTable table) {
var model = (DefaultTableModel) table.getModel();
var data = new HashMap<String, Object>();
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();
}
}

View file

@ -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<OpenAIMessage> = 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>(
CustomOpenAIChatCompletionRequest.serializer()
)

View file

@ -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<Message.Response> {
return when {
this is OpenAIMessage.Assistant && !this.toolCalls.isNullOrEmpty() -> {
val result = mutableListOf<Message.Response>()
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<CustomOpenAIChatCompletionRequest>) :
JsonTransformingSerializer<CustomOpenAIChatCompletionRequest>(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
}
}
}

View file

@ -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<String, JsonElement>
?: 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<String, Any>.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<String, JsonElement>? {
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<String>? = asStringList(allowEmpty = false)
private fun Any?.asStringList(allowEmpty: Boolean = false): List<String>? = 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,

View file

@ -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)
}
}
}

View file

@ -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<OpenAIChatCompletionMessage>,
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<OpenAIChatCompletionMessage>): String {
return messages.joinToString(separator = "\n\n") { message ->
when (message) {
is OpenAIChatCompletionStandardMessage -> message.content
else -> ObjectMapper().writeValueAsString(message)
}
}
}
}
}

View file

@ -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
}
}
}

View file

@ -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<String, String>,
body: Map<String, *>,
) : 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<String, String>
get() = getHeadersData(headersTable).toMutableMap()
set(value) {
setHeadersData(headersTable, value)
}
@Suppress("UNCHECKED_CAST")
var body: MutableMap<String, Any>
get() = getBodyData(bodyTable) as MutableMap<String, Any>
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<String, String>): 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<String, *>): 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<String, String>) {
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<String, String> {
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<String, *>) {
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<String, Any?> {
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<String> {
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")
}
}

View file

@ -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<T>(
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<Shortcut>()
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<Int, Int> {
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+)""")
}
}

View file

@ -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<String, String> {
if (rawJson.isBlank()) {
return emptyMap()
}
return try {
val parsed =
objectMapper.readValue(rawJson, object : TypeReference<Map<String, Any?>>() {})
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<String, Any?> {
if (rawJson.isBlank()) {
return emptyMap()
}
return try {
objectMapper.readValue(rawJson, object : TypeReference<Map<String, Any?>>() {})
} 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.")
}
}
}

View file

@ -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<CustomServicePlaceholder> = 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<String> = all.mapTo(linkedSetOf()) { it.code }
private val byCode: Map<String, CustomServicePlaceholder> = 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
}

View file

@ -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<String>,
) : 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<String>,
) : 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")
}

View file

@ -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,
)

View file

@ -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)
}
}
}
}

View file

@ -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<OpenAIChatCompletionMessage>(
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<OpenAIChatCompletionMessage>(
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())
}
}