From ec86b650bdd92d201cc3908f083b370f3b0a5332 Mon Sep 17 00:00:00 2001 From: Hai Phuc Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:03:43 -0700 Subject: [PATCH] feat(openai-compatible): add template-based provider configuration with locked settings - Introduce OpenAiCompatibleSettings.isTemplate flag to lock apiMode and httpVersionConfig for template instances - Preserve template base settings during wizard flow to ensure locked values survive field mapping - Refactor instance config screens to hide locked fields and display template description as a select field hint - Add HttpVersion enum and apiMode/httpVersion properties to OpenAiCompatibleTemplate definitions - Increase UI consistency by using composable config section wrapper in AppComponents and standardizing label styles Refs: #123 --- .../io/askimo/ui/common/components/Buttons.kt | 61 +++++++-- .../askimo/ui/common/theme/AppComponents.kt | 49 +++++++ .../askimo/ui/mcp/McpServerCatalogDialog.kt | 2 +- .../settings/ProviderSelectionDialog.kt | 27 ++-- .../settings/ProviderWizardViewModel.kt | 24 +++- .../desktop/shell/ProviderModelPanel.kt | 68 +++++----- .../OpenAiCompatibleSettings.kt | 122 +++++++++++------- .../OpenAiCompatibleTemplate.kt | 26 ++-- 8 files changed, 255 insertions(+), 124 deletions(-) diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/components/Buttons.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/components/Buttons.kt index 184b9696..d8bd9206 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/components/Buttons.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/components/Buttons.kt @@ -5,14 +5,27 @@ package io.askimo.ui.common.components import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon @@ -149,13 +162,41 @@ fun linkButton( enabled: Boolean = true, content: @Composable RowScope.() -> Unit, ) { - TextButton( - onClick = onClick, - enabled = enabled, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), - modifier = modifier.pointerHoverIcon(PointerIcon.Hand, overrideDescendants = true), - content = content, - ) + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val contentColor = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } + + CompositionLocalProvider(LocalContentColor provides contentColor) { + Row( + modifier = modifier + .hoverable(interactionSource) + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClick = onClick, + ) + .pointerHoverIcon(PointerIcon.Hand, overrideDescendants = true) + .padding(horizontal = 8.dp, vertical = 4.dp) + .drawBehind { + if (isHovered && enabled) { + val strokeWidth = 1.dp.toPx() + drawLine( + color = contentColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = strokeWidth, + ) + } + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + content() + } + } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt index 55289e70..61848c0c 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt @@ -20,8 +20,10 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -67,6 +69,7 @@ import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.material3.TextFieldColors import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -405,6 +408,52 @@ object AppComponents { ) } + // ── Form Fields ─────────────────────────────────────────────────────────── + + /** + * Standardised form-field layout that enforces a consistent vertical rhythm: + * + * ``` + * [Label] ┐ + * [Description] ┘ extraSmall gap — label and description belong together + * small gap — breath before the interactive control + * [content slot] ← text field, secret field, button row, etc. + * [hint slot] extraSmall gap — hint is a sub-annotation of the input + * ``` + * + * + * @param label Field label rendered in [AppTextStyles.groupTitle]. + * @param description One-line helper text rendered in [AppTextStyles.caption]. + * @param required When `true`, appends " *" to the label. + * @param hint Optional composable rendered below [content] with [Spacing.extraSmall] + * top gap (e.g. the currently-selected option description for a SelectField). + * @param content The interactive control (text field, button row, etc.). + */ + @Composable + fun formField( + label: String, + description: String, + modifier: Modifier = Modifier, + required: Boolean = false, + hint: (@Composable () -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, + ) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = if (required) "$label *" else label, + style = AppTextStyles.groupTitle, + ) + Spacer(Modifier.height(Spacing.extraSmall)) + Text(text = description, style = AppTextStyles.caption) + Spacer(Modifier.height(Spacing.small)) + content() + if (hint != null) { + Spacer(Modifier.height(Spacing.extraSmall)) + hint() + } + } + } + @Composable fun menuItemColors(): MenuItemColors = MenuDefaults.itemColors( textColor = MaterialTheme.colorScheme.onSurface, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/mcp/McpServerCatalogDialog.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/mcp/McpServerCatalogDialog.kt index e2668340..190c9a7e 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/mcp/McpServerCatalogDialog.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/mcp/McpServerCatalogDialog.kt @@ -235,7 +235,7 @@ private fun mcpTemplateCatalogCard( onClick = onSelect, modifier = Modifier.fillMaxWidth().pointerHoverIcon(PointerIcon.Hand), ) { - Text(stringResource("mcp.catalog.card.configure"), style = AppTextStyles.fieldLabel) + Text(stringResource("mcp.catalog.card.configure")) } } } diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt index 1fa68e56..ff65e259 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt @@ -604,9 +604,21 @@ private fun instanceConfigScreen(viewModel: ProviderWizardViewModel) { } else -> { - Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall)) { - Text(text = field.label + if (field.required) " *" else "", style = AppTextStyles.groupTitle) - Text(text = field.description, style = AppTextStyles.caption) + val fieldHint: (@Composable () -> Unit)? = if (field is ProviderConfigField.SelectField) { + val currentValue = viewModel.providerFieldValues[field.name] ?: field.value + field.options.find { it.value == currentValue }?.description + ?.takeIf { it.isNotBlank() } + ?.let { desc -> { Text(text = desc, style = AppTextStyles.caption) } } + } else { + null + } + + AppComponents.formField( + label = field.label, + description = field.description, + required = field.required, + hint = fieldHint, + ) { when (field) { is ProviderConfigField.ApiKeyField -> { AppComponents.appSecretTextField( @@ -660,14 +672,9 @@ private fun instanceConfigScreen(viewModel: ProviderWizardViewModel) { } } } - field.options.find { it.value == currentValue }?.description - ?.takeIf { it.isNotBlank() }?.let { endpoint -> - Text( - text = endpoint, - style = AppTextStyles.caption, - ) - } } + + else -> {} } } } diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderWizardViewModel.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderWizardViewModel.kt index 9fa6ff3d..e4815620 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderWizardViewModel.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderWizardViewModel.kt @@ -134,6 +134,15 @@ class ProviderWizardViewModel( var providerFieldValues by mutableStateOf>(emptyMap()) private set + /** + * Base settings pre-filled from a [OpenAiCompatibleTemplate] during the add-wizard flow. + * Used by [buildValidatedSettings] so that template-locked values ([OpenAiCompatibleSettings.isTemplate], + * [OpenAiCompatibleSettings.apiMode], [OpenAiCompatibleSettings.httpVersionConfig]) survive + * the [applyConfigFields] call when saving a new template-based instance. + * Reset to null whenever the wizard is reset or a non-template provider is selected. + */ + private var templateBaseSettings: ProviderSettings? = null + // ── Connection / fetch state ────────────────────────────────────────────────────────────── var isTestingConnection by mutableStateOf(false) @@ -335,14 +344,20 @@ class ProviderWizardViewModel( wizardStep = WizardStep.CONFIG resetWizardFormState() - val prefilled = OpenAiCompatibleSettings(baseUrl = template.baseUrl, apiMode = template.apiMode) + val prefilled = OpenAiCompatibleSettings( + baseUrl = template.baseUrl, + apiMode = template.apiMode, + httpVersionConfig = template.httpVersion, + isTemplate = true, + ) + templateBaseSettings = prefilled providerConfigFields = prefilled.getConfigFields(LocalizationManager.messageResolver) providerFieldValues = buildMap { providerConfigFields.forEach { field -> when (field) { is ProviderConfigField.ApiKeyField -> put(field.name, field.value) is ProviderConfigField.BaseUrlField -> put(field.name, template.baseUrl) - is ProviderConfigField.SelectField -> put(field.name, template.apiMode.name) + is ProviderConfigField.SelectField -> put(field.name, field.value) is ProviderConfigField.InfoField -> Unit } } @@ -512,7 +527,11 @@ class ProviderWizardViewModel( * callers inside a `withContext` block can simply `return@withContext e.failure`. */ private fun buildValidatedSettings(provider: ModelProvider): ProviderSettings { + // For new template instances, use templateBaseSettings (which carries isTemplate=true, + // the preset apiMode, and httpVersionConfig) instead of the generic defaultSettings(). + // For editing an existing instance, the persisted settings already carry isTemplate. val baseSettings = editingInstance?.settings + ?: templateBaseSettings ?: ProviderRegistry.getFactory(provider)?.defaultSettings() val settings = baseSettings?.applyConfigFields(providerFieldValues) @@ -643,6 +662,7 @@ class ProviderWizardViewModel( private fun resetWizardFormState() { autoFetchJob?.cancel() + templateBaseSettings = null connectionError = null connectionErrorHelp = null connectionTestSuccess = false diff --git a/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt b/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt index c6e18711..1ecca2f9 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt @@ -651,16 +651,11 @@ private fun instanceEditForm( } is ProviderConfigField.ApiKeyField -> { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall)) { - Text( - text = field.label + if (field.required) " *" else "", - style = AppTextStyles.fieldLabel, - ) - Text( - text = field.description, - style = AppTextStyles.caption, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + AppComponents.formField( + label = field.label, + description = field.description, + required = field.required, + ) { AppComponents.appSecretTextField( value = state.editFieldValues[field.name] ?: "", onValueChange = { state.updateEditField(field.name, it) }, @@ -676,16 +671,11 @@ private fun instanceEditForm( } is ProviderConfigField.BaseUrlField -> { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall)) { - Text( - text = field.label + if (field.required) " *" else "", - style = AppTextStyles.fieldLabel, - ) - Text( - text = field.description, - style = AppTextStyles.caption, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + AppComponents.formField( + label = field.label, + description = field.description, + required = field.required, + ) { OutlinedTextField( value = state.editFieldValues[field.name] ?: "", onValueChange = { state.updateEditField(field.name, it) }, @@ -699,17 +689,25 @@ private fun instanceEditForm( } is ProviderConfigField.SelectField -> { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall)) { - Text( - text = field.label, - style = AppTextStyles.fieldLabel, - ) - Text( - text = field.description, - style = AppTextStyles.caption, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - val currentValue = state.editFieldValues[field.name] ?: field.value + val currentValue = state.editFieldValues[field.name] ?: field.value + val selectHint: (@Composable () -> Unit)? = field.options + .find { it.value == currentValue }?.description + ?.takeIf { it.isNotBlank() } + ?.let { desc -> + { + Text( + text = desc, + style = AppTextStyles.caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + AppComponents.formField( + label = field.label, + description = field.description, + required = field.required, + hint = selectHint, + ) { Row(horizontalArrangement = Arrangement.spacedBy(Spacing.small)) { field.options.forEach { option -> if (currentValue == option.value) { @@ -723,14 +721,6 @@ private fun instanceEditForm( } } } - field.options.find { it.value == currentValue }?.description - ?.takeIf { it.isNotBlank() }?.let { endpoint -> - Text( - text = endpoint, - style = AppTextStyles.caption, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } } diff --git a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleSettings.kt b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleSettings.kt index e9141eee..9444341e 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleSettings.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleSettings.kt @@ -36,6 +36,14 @@ data class OpenAiCompatibleSettings( * Existing serialised configs without this field deserialise to the default safely. */ val httpVersionConfig: HttpVersion = HttpVersion.HTTP_1_1, + /** + * Whether this instance was created from a predefined [OpenAiCompatibleTemplate]. + * When `true`, [apiMode] and [httpVersionConfig] are locked to the template's preset + * values and the corresponding UI fields are hidden in the config screen. + * Set once at creation time by the wizard; never mutated through the field-map path. + * Existing serialised configs without this field deserialise to `false` safely. + */ + val isTemplate: Boolean = false, ) : ProviderSettings, HasApiKey, HasBaseUrl { @@ -107,54 +115,68 @@ data class OpenAiCompatibleSettings( messageResolver("provider.openai_compatible.apikey.description") } - return listOf( - ProviderConfigField.BaseUrlField( - description = messageResolver("provider.openai_compatible.baseurl.description"), - value = baseUrl, - ), - ProviderConfigField.ApiKeyField( - description = apiKeyDescription, - value = apiKey, - required = false, - hasExistingValue = hasStoredKey, - ), - ProviderConfigField.SelectField( - name = SettingField.API_MODE, - label = messageResolver("provider.openai_compatible.apimode.label"), - description = messageResolver("provider.openai_compatible.apimode.description"), - value = apiMode.name, - options = listOf( - SelectOption( - value = OpenAiApiMode.CHAT_COMPLETIONS.name, - label = messageResolver("provider.openai_compatible.apimode.chat_completions"), - description = "/v1/chat/completions", - ), - SelectOption( - value = OpenAiApiMode.RESPONSES.name, - label = messageResolver("provider.openai_compatible.apimode.responses"), - description = "/v1/responses", - ), + return buildList { + add( + ProviderConfigField.BaseUrlField( + description = messageResolver("provider.openai_compatible.baseurl.description"), + value = baseUrl, ), - ), - ProviderConfigField.SelectField( - name = SettingField.HTTP_VERSION, - label = messageResolver("provider.openai_compatible.httpversion.label"), - description = messageResolver("provider.openai_compatible.httpversion.description"), - value = httpVersionConfig.name, - options = listOf( - SelectOption( - value = HttpVersion.HTTP_1_1.name, - label = "HTTP/1.1", - description = messageResolver("provider.openai_compatible.httpversion.http1_1"), - ), - SelectOption( - value = HttpVersion.HTTP_2.name, - label = "HTTP/2", - description = messageResolver("provider.openai_compatible.httpversion.http2"), + ) + add( + ProviderConfigField.ApiKeyField( + description = apiKeyDescription, + value = apiKey, + required = false, + hasExistingValue = hasStoredKey, + ), + ) + // API mode is always shown — power users may need to switch it even on template + // instances (e.g. when a provider adds Responses API support after setup). + add( + ProviderConfigField.SelectField( + name = SettingField.API_MODE, + label = messageResolver("provider.openai_compatible.apimode.label"), + description = messageResolver("provider.openai_compatible.apimode.description"), + value = apiMode.name, + options = listOf( + SelectOption( + value = OpenAiApiMode.CHAT_COMPLETIONS.name, + label = messageResolver("provider.openai_compatible.apimode.chat_completions"), + description = "/v1/chat/completions", + ), + SelectOption( + value = OpenAiApiMode.RESPONSES.name, + label = messageResolver("provider.openai_compatible.apimode.responses"), + description = "/v1/responses", + ), ), ), - ), - ) + ) + // HTTP version is a low-level transport detail — hidden for template instances + // where the correct value is already pre-set and misconfigurations silently break things. + if (!isTemplate) { + add( + ProviderConfigField.SelectField( + name = SettingField.HTTP_VERSION, + label = messageResolver("provider.openai_compatible.httpversion.label"), + description = messageResolver("provider.openai_compatible.httpversion.description"), + value = httpVersionConfig.name, + options = listOf( + SelectOption( + value = HttpVersion.HTTP_1_1.name, + label = "HTTP/1.1", + description = messageResolver("provider.openai_compatible.httpversion.http1_1"), + ), + SelectOption( + value = HttpVersion.HTTP_2.name, + label = "HTTP/2", + description = messageResolver("provider.openai_compatible.httpversion.http2"), + ), + ), + ), + ) + } + } } override fun applyConfigFields(fields: Map): ProviderSettings { @@ -163,9 +185,13 @@ data class OpenAiCompatibleSettings( val newApiMode = fields[SettingField.API_MODE] ?.let { runCatching { OpenAiApiMode.valueOf(it) }.getOrNull() } ?: apiMode - val newHttpVersion = fields[SettingField.HTTP_VERSION] - ?.let { runCatching { HttpVersion.valueOf(it) }.getOrNull() } - ?: httpVersionConfig + val newHttpVersion = if (isTemplate) { + httpVersionConfig + } else { + fields[SettingField.HTTP_VERSION] + ?.let { runCatching { HttpVersion.valueOf(it) }.getOrNull() } + ?: httpVersionConfig + } return copy(baseUrl = newBaseUrl, apiKey = newApiKey, apiMode = newApiMode, httpVersionConfig = newHttpVersion) } diff --git a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleTemplate.kt b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleTemplate.kt index a640b4fc..afcfc5e9 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleTemplate.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleTemplate.kt @@ -4,6 +4,8 @@ */ package io.askimo.core.providers.openaicompatible +import io.askimo.core.providers.HttpVersion + /** * Predefined OpenAI-compatible cloud provider templates. * @@ -29,6 +31,13 @@ enum class OpenAiCompatibleTemplate( val helpText: String, /** API mode pre-selected when the user connects via this template. */ val apiMode: OpenAiApiMode = OpenAiApiMode.CHAT_COMPLETIONS, + /** + * HTTP protocol version for connections to this provider. + * All predefined cloud providers default to [HttpVersion.HTTP_2] — they run on modern + * infrastructure that supports multiplexing. Override to [HttpVersion.HTTP_1_1] only for + * providers known to have HTTP/2 issues. + */ + val httpVersion: HttpVersion = HttpVersion.HTTP_2, ) { CLOUDFLARE_AI( displayName = "Cloudflare AI", @@ -51,6 +60,7 @@ enum class OpenAiCompatibleTemplate( baseUrl = "https://api.groq.com/openai/v1", apiKeyRequired = true, apiKeyUrl = "https://console.groq.com/keys", + apiMode = OpenAiApiMode.RESPONSES, helpText = "💡 To use Groq:\n\n" + "1. Get your free API key at: console.groq.com/keys\n" + "2. Enter a model name, e.g. llama-3.1-8b-instant\n" + @@ -70,20 +80,6 @@ enum class OpenAiCompatibleTemplate( "3. Click Next to browse all available models", ), - OLLAMA_CLOUD( - displayName = "Ollama (Remote)", - initials = "OL", - tagline = "Connect to an Ollama instance running on a remote server or cloud VM.", - baseUrl = "http://your-server:11434/v1", - apiKeyRequired = false, - apiKeyUrl = "", - helpText = "💡 To connect a remote Ollama instance:\n\n" + - "1. On your server, set: OLLAMA_HOST=0.0.0.0\n" + - "2. Restart Ollama so it listens on all interfaces\n" + - "3. Replace the Base URL with your server's address,\n" + - " e.g. http://192.168.1.10:11434/v1", - ), - OPENROUTER( displayName = "OpenRouter", initials = "OR", @@ -91,6 +87,7 @@ enum class OpenAiCompatibleTemplate( baseUrl = "https://openrouter.ai/api/v1", apiKeyRequired = true, apiKeyUrl = "https://openrouter.ai/keys", + apiMode = OpenAiApiMode.RESPONSES, helpText = "💡 To use OpenRouter:\n\n" + "1. Get your free API key at: openrouter.ai/keys\n" + "2. Click Next to browse 300+ available models\n" + @@ -104,6 +101,7 @@ enum class OpenAiCompatibleTemplate( baseUrl = "https://api.together.xyz/v1", apiKeyRequired = true, apiKeyUrl = "https://api.together.ai/settings/api-keys", + httpVersion = HttpVersion.HTTP_1_1, helpText = "💡 To use Together AI:\n\n" + "1. Get your API key at: api.together.ai/settings/api-keys\n" + "2. Click Next to browse available models,\n" +