mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-07-10 01:39:13 +00:00
feat: custom openai nested request params
This commit is contained in:
parent
6a7107c6e7
commit
0442d85dde
15 changed files with 1918 additions and 284 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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+)""")
|
||||
}
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue