From 2b44937368abed99b9cbc566d39f28f2fd618420 Mon Sep 17 00:00:00 2001 From: Garfie Date: Tue, 28 Apr 2026 20:21:28 -0600 Subject: [PATCH] fix(chat): wire format for custom and user_provided endpoints (#60) ChatPayloadBuilder caught EModelEndpoint.valueOf for custom endpoint names (OpenRouter, Deepseek, etc.) and silently sent endpoint=agents, producing 403 "Forbidden: Insufficient permissions" on the server. Type endpoint identity as String end-to-end, add EndpointClassifier to derive endpointType / key / modelDisplayLabel from /api/config, and propagate the dispatch fields to all five chat-send sites in ChatViewModel (doSendMessage, editUserMessage, editAiMessage, regenerateMessageNow, continueGenerationNow). A normalization shim in ConversationMapper converts legacy enum-name rows in the Room cache back to wire format on read. Verified end-to-end against a docker LibreChat v0.8.5 instance with OpenRouter user_provided keys; built-in .env Anthropic regression also passes. --- .gitignore | 5 + .../core/data/endpoint/EndpointClassifier.kt | 34 +++++++ .../core/data/mapper/ConversationMapper.kt | 22 +++-- .../data/repository/ChatPayloadBuilder.kt | 15 ++- .../core/data/repository/ChatRepository.kt | 3 + .../data/repository/ChatRepositoryImpl.kt | 6 ++ .../data/endpoint/EndpointClassifierTest.kt | 74 +++++++++++++++ .../ConversationMapperNormalizationTest.kt | 56 +++++++++++ .../data/repository/ChatPayloadBuilderTest.kt | 66 +++++++++++++ .../librechat/core/model/Conversation.kt | 4 +- .../librechat/core/model/EModelEndpoint.kt | 24 ++++- .../garfiec/librechat/core/model/Preset.kt | 4 +- .../core/model/request/AddedConversation.kt | 7 +- .../core/model/request/ChatRequest.kt | 6 +- .../model/ConversationSerializationTest.kt | 24 ++++- .../core/model/EnumSerializationTest.kt | 22 +++++ .../core/ui/components/EndpointIcons.kt | 11 --- .../ChatViewModelEndpointDispatchTest.kt | 92 +++++++++++++++++++ .../feature/chat/viewmodel/ChatViewModel.kt | 31 ++++++- .../delegate/EModelEndpointExtensions.kt | 15 --- .../delegate/ModelSelectionDelegate.kt | 27 ++---- .../delegate/PresetPromptDelegate.kt | 11 +-- .../ArchivedConversationDisplayData.kt | 2 +- .../components/ConversationDisplayData.kt | 3 +- .../components/ConversationItem.kt | 7 +- .../viewmodel/PresetManagerViewModel.kt | 2 +- .../shared/navigation/DrawerContent.kt | 4 +- .../shared/navigation/DrawerUiModels.kt | 3 +- 28 files changed, 481 insertions(+), 99 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/endpoint/EndpointClassifier.kt create mode 100644 core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/endpoint/EndpointClassifierTest.kt create mode 100644 core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapperNormalizationTest.kt create mode 100644 core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/repository/ChatPayloadBuilderTest.kt create mode 100644 feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModelEndpointDispatchTest.kt delete mode 100644 feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/EModelEndpointExtensions.kt diff --git a/.gitignore b/.gitignore index 7bc2dd1..bbd4dd9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ local.properties # OS .DS_Store Thumbs.db + +# Local Claude Code working notes (issue repros, scratch captures with secrets) +.claude/issue-*/ +.claude/worktrees/ +.claude/sync-upstream/ diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/endpoint/EndpointClassifier.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/endpoint/EndpointClassifier.kt new file mode 100644 index 0000000..104d6c6 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/endpoint/EndpointClassifier.kt @@ -0,0 +1,34 @@ +package com.garfiec.librechat.core.data.endpoint + +import com.garfiec.librechat.core.model.EModelEndpoint +import com.garfiec.librechat.core.model.EndpointConfig + +data class EndpointDispatch( + val endpointType: String?, + val key: String?, + val modelDisplayLabel: String?, +) + +object EndpointClassifier { + /** + * Resolve the wire fields for a chat-send request, mirroring web's + * getEndpointField(endpointsConfig, endpoint, 'type') + getExpiry() pattern. + * + * - endpointType: prefers EndpointConfig.type from /api/config; falls back to + * the endpoint name itself if it's a known built-in (covers cold-start + * race where /api/config hasn't loaded); otherwise "custom". + * - key: "never" when the endpoint is user_provided (matches web's + * getExpiry() returning expiresAt || "never"). null otherwise so the + * field is omitted from the wire body. Time-limited keys are a follow-up. + * - modelDisplayLabel: from config; falls back to the endpoint name. + */ + fun classify(endpointName: String, configs: Map): EndpointDispatch { + val config = configs[endpointName] + val endpointType = config?.type + ?: endpointName.takeIf { it in EModelEndpoint.BUILT_IN_NAMES } + ?: "custom" + val key = if (config?.userProvide == true) "never" else null + val modelDisplayLabel = config?.modelDisplayLabel ?: endpointName + return EndpointDispatch(endpointType, key, modelDisplayLabel) + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapper.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapper.kt index 1ebe453..f3880b2 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapper.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapper.kt @@ -11,12 +11,22 @@ import kotlin.time.Instant private val json = Json { ignoreUnknownKeys = true } +// Legacy compat: pre-fix rows stored the Kotlin enum `.name` (e.g. "OPENAI"). +// Remove once a Room schema migration normalizes the column to wire format. +private fun normalizeEndpoint(stored: String?): String? { + if (stored == null) return null + if (stored in EModelEndpoint.BUILT_IN_NAMES) return stored + runCatching { EModelEndpoint.valueOf(stored).toSerialName() } + .getOrNull()?.let { return it } + return stored +} + fun Conversation.toEntity(): ConversationEntity = ConversationEntity( conversationId = conversationId ?: "", title = title ?: "New Chat", user = user ?: "", - endpoint = endpoint?.name, - endpointType = endpointType?.name, + endpoint = endpoint, + endpointType = endpointType, model = model, agentId = agentId, isArchived = isArchived, @@ -32,12 +42,8 @@ fun ConversationEntity.toModel(): Conversation = Conversation( conversationId = conversationId, title = title, user = user, - endpoint = endpoint?.let { name -> - EModelEndpoint.entries.find { it.name == name } - }, - endpointType = endpointType?.let { name -> - EModelEndpoint.entries.find { it.name == name } - }, + endpoint = normalizeEndpoint(endpoint), + endpointType = normalizeEndpoint(endpointType), model = model, agentId = agentId, isArchived = isArchived, diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatPayloadBuilder.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatPayloadBuilder.kt index 0677f24..a54ffba 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatPayloadBuilder.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatPayloadBuilder.kt @@ -1,6 +1,5 @@ package com.garfiec.librechat.core.data.repository -import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.model.FileReference import com.garfiec.librechat.core.model.request.AddedConversation import com.garfiec.librechat.core.model.request.ChatRequest @@ -13,6 +12,9 @@ object ChatPayloadBuilder { text: String, conversationId: String?, endpoint: String, + endpointType: String? = null, + key: String? = null, + modelDisplayLabel: String? = null, model: String?, parentMessageId: String? = null, agentId: String? = null, @@ -26,19 +28,16 @@ object ChatPayloadBuilder { addedConvo: AddedConversation? = null, ephemeralAgent: EphemeralAgent? = null, ): ChatRequest { - val resolvedEndpoint = try { - EModelEndpoint.valueOf(endpoint.uppercase()) - } catch (_: IllegalArgumentException) { - EModelEndpoint.AGENTS - } - val resolvedParentMessageId = parentMessageId ?: NO_PARENT return ChatRequest( text = text, conversationId = conversationId, parentMessageId = resolvedParentMessageId, - endpoint = resolvedEndpoint, + endpoint = endpoint, + endpointType = endpointType, + key = key, + modelDisplayLabel = modelDisplayLabel, model = model, agentId = agentId, overrideParentMessageId = overrideParentMessageId, diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatRepository.kt index b9b0f15..95dc90c 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatRepository.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatRepository.kt @@ -13,6 +13,9 @@ interface ChatRepository { text: String, conversationId: String?, endpoint: String, + endpointType: String? = null, + key: String? = null, + modelDisplayLabel: String? = null, model: String?, parentMessageId: String? = null, agentId: String? = null, diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatRepositoryImpl.kt index d2c9174..3742750 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ChatRepositoryImpl.kt @@ -24,6 +24,9 @@ class ChatRepositoryImpl( text: String, conversationId: String?, endpoint: String, + endpointType: String?, + key: String?, + modelDisplayLabel: String?, model: String?, parentMessageId: String?, agentId: String?, @@ -42,6 +45,9 @@ class ChatRepositoryImpl( text = text, conversationId = conversationId, endpoint = endpoint, + endpointType = endpointType, + key = key, + modelDisplayLabel = modelDisplayLabel, model = model, parentMessageId = parentMessageId, agentId = agentId, diff --git a/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/endpoint/EndpointClassifierTest.kt b/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/endpoint/EndpointClassifierTest.kt new file mode 100644 index 0000000..4b44922 --- /dev/null +++ b/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/endpoint/EndpointClassifierTest.kt @@ -0,0 +1,74 @@ +package com.garfiec.librechat.core.data.endpoint + +import com.garfiec.librechat.core.model.EndpointConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class EndpointClassifierTest { + + @Test + fun customEndpointWithoutConfig_returnsCustom() { + val dispatch = EndpointClassifier.classify("OpenRouter", emptyMap()) + assertEquals("custom", dispatch.endpointType) + assertNull(dispatch.key) + assertEquals("OpenRouter", dispatch.modelDisplayLabel) + } + + @Test + fun builtInEndpointWithColdStartConfig_usesFallback() { + val dispatch = EndpointClassifier.classify("openAI", emptyMap()) + assertEquals("openAI", dispatch.endpointType) + assertNull(dispatch.key) + } + + @Test + fun userProvidedEndpointSendsKey() { + val configs = mapOf( + "OpenRouter" to EndpointConfig( + type = "custom", + userProvide = true, + modelDisplayLabel = "OpenRouter", + ), + ) + val dispatch = EndpointClassifier.classify("OpenRouter", configs) + assertEquals("never", dispatch.key) + assertEquals("custom", dispatch.endpointType) + } + + @Test + fun envKeyEndpointOmitsKey() { + val configs = mapOf( + "anthropic" to EndpointConfig( + type = "anthropic", + userProvide = false, + ), + ) + val dispatch = EndpointClassifier.classify("anthropic", configs) + assertNull(dispatch.key) + } + + @Test + fun customOverridingBuiltInNamePrefersConfigType() { + val configs = mapOf( + "openAI" to EndpointConfig( + type = "custom", + userProvide = true, + ), + ) + val dispatch = EndpointClassifier.classify("openAI", configs) + assertEquals("custom", dispatch.endpointType) + } + + @Test + fun modelDisplayLabelFallsBackToEndpointName() { + val configs = mapOf( + "OpenRouter" to EndpointConfig( + type = "custom", + modelDisplayLabel = null, + ), + ) + val dispatch = EndpointClassifier.classify("OpenRouter", configs) + assertEquals("OpenRouter", dispatch.modelDisplayLabel) + } +} diff --git a/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapperNormalizationTest.kt b/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapperNormalizationTest.kt new file mode 100644 index 0000000..32f53c5 --- /dev/null +++ b/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapperNormalizationTest.kt @@ -0,0 +1,56 @@ +package com.garfiec.librechat.core.data.mapper + +import com.garfiec.librechat.core.data.db.entity.ConversationEntity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ConversationMapperNormalizationTest { + + private fun entity(endpoint: String?, endpointType: String? = null) = ConversationEntity( + conversationId = "c1", + title = "t", + user = "u", + endpoint = endpoint, + endpointType = endpointType, + model = null, + agentId = null, + isArchived = false, + tags = "[]", + iconURL = null, + greeting = null, + modelParams = null, + createdAt = 0L, + updatedAt = 0L, + ) + + @Test + fun legacyEnumNameConvertsToWireFormat() { + assertEquals("openAI", entity("OPENAI").toModel().endpoint) + assertEquals("azureOpenAI", entity("AZURE_OPENAI").toModel().endpoint) + assertEquals("agents", entity("AGENTS").toModel().endpoint) + } + + @Test + fun wireFormatPassesThroughUnchanged() { + assertEquals("openAI", entity("openAI").toModel().endpoint) + assertEquals("anthropic", entity("anthropic").toModel().endpoint) + } + + @Test + fun customEndpointNamePassesThroughUnchanged() { + assertEquals("OpenRouter", entity("OpenRouter").toModel().endpoint) + assertEquals("Deepseek", entity("Deepseek").toModel().endpoint) + } + + @Test + fun nullInputReturnsNull() { + assertNull(entity(null).toModel().endpoint) + assertNull(entity("openAI", endpointType = null).toModel().endpointType) + } + + @Test + fun unrecognizedInputPassesThroughAsIs() { + assertEquals("SomeUnknownThing", entity("SomeUnknownThing").toModel().endpoint) + } +} diff --git a/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/repository/ChatPayloadBuilderTest.kt b/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/repository/ChatPayloadBuilderTest.kt new file mode 100644 index 0000000..aa096fa --- /dev/null +++ b/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/repository/ChatPayloadBuilderTest.kt @@ -0,0 +1,66 @@ +package com.garfiec.librechat.core.data.repository + +import com.garfiec.librechat.core.model.request.ChatRequest +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ChatPayloadBuilderTest { + + private val json = Json { encodeDefaults = false } + + @Test + fun customEndpointWireFormatNeverFallsBackToAgents() { + val req = ChatPayloadBuilder.build( + text = "hello", + conversationId = null, + endpoint = "Deepseek", + endpointType = "custom", + key = "never", + model = "deepseek-chat", + ) + + assertEquals("Deepseek", req.endpoint) + assertEquals("custom", req.endpointType) + assertEquals("never", req.key) + + val encoded = json.encodeToString(ChatRequest.serializer(), req) + assertTrue("\"endpoint\":\"Deepseek\"" in encoded, "endpoint must be literal Deepseek") + assertFalse("\"endpoint\":\"agents\"" in encoded, "endpoint must NOT fall back to agents") + } + + @Test + fun builtInUserProvidedEndpointSendsKey() { + val req = ChatPayloadBuilder.build( + text = "hi", + conversationId = null, + endpoint = "openAI", + endpointType = "openAI", + key = "never", + model = "gpt-4o", + ) + assertEquals("openAI", req.endpoint) + assertEquals("openAI", req.endpointType) + assertEquals("never", req.key) + } + + @Test + fun builtInEnvKeyEndpointOmitsKey() { + val req = ChatPayloadBuilder.build( + text = "hi", + conversationId = null, + endpoint = "anthropic", + endpointType = "anthropic", + key = null, + model = "claude-sonnet-4-6", + ) + assertEquals("anthropic", req.endpoint) + assertNull(req.key) + + val encoded = json.encodeToString(ChatRequest.serializer(), req) + assertFalse("\"key\"" in encoded, "key must be omitted (not encoded as null) when null") + } +} diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt index 9cdcad3..af090b0 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt @@ -8,8 +8,8 @@ data class Conversation( val conversationId: String? = null, val title: String? = "New Chat", val user: String? = null, - val endpoint: EModelEndpoint? = null, - val endpointType: EModelEndpoint? = null, + val endpoint: String? = null, + val endpointType: String? = null, val model: String? = null, @SerialName("agent_id") val agentId: String? = null, @SerialName("assistant_id") val assistantId: String? = null, diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/EModelEndpoint.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/EModelEndpoint.kt index 01a2d18..3f01d51 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/EModelEndpoint.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/EModelEndpoint.kt @@ -30,5 +30,27 @@ enum class EModelEndpoint { CUSTOM, @SerialName("bedrock") - BEDROCK, + BEDROCK; + + /** Wire-format name for this enum value (matches `@SerialName`). */ + fun toSerialName(): String = when (this) { + AZURE_OPENAI -> "azureOpenAI" + OPENAI -> "openAI" + GOOGLE -> "google" + ANTHROPIC -> "anthropic" + ASSISTANTS -> "assistants" + AZURE_ASSISTANTS -> "azureAssistants" + AGENTS -> "agents" + CUSTOM -> "custom" + BEDROCK -> "bedrock" + } + + companion object { + /** Wire-format names for all built-in endpoints. */ + val BUILT_IN_NAMES: Set = entries.map { it.toSerialName() }.toSet() + + /** Resolve a wire-format name back to the enum, or null for custom-endpoint names. */ + fun fromName(name: String): EModelEndpoint? = + entries.firstOrNull { it.toSerialName() == name } + } } diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Preset.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Preset.kt index ca6d76b..d620591 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Preset.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Preset.kt @@ -10,8 +10,8 @@ data class Preset( val user: String? = null, val defaultPreset: Boolean? = null, val order: Int? = null, - val endpoint: EModelEndpoint? = null, - val endpointType: EModelEndpoint? = null, + val endpoint: String? = null, + val endpointType: String? = null, val model: String? = null, @SerialName("agent_id") val agentId: String? = null, val temperature: Double? = null, diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddedConversation.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddedConversation.kt index f07f196..4da979a 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddedConversation.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddedConversation.kt @@ -1,6 +1,5 @@ package com.garfiec.librechat.core.model.request -import com.garfiec.librechat.core.model.EModelEndpoint import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -8,8 +7,10 @@ import kotlinx.serialization.Serializable data class AddedConversation( val conversationId: String? = null, val parentMessageId: String? = null, - val endpoint: EModelEndpoint? = null, - val endpointType: EModelEndpoint? = null, + val endpoint: String? = null, + val endpointType: String? = null, + val modelDisplayLabel: String? = null, + val key: String? = null, @SerialName("agent_id") val agentId: String? = null, val model: String? = null, val modelLabel: String? = null, diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/ChatRequest.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/ChatRequest.kt index b2a0768..6a2e3c9 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/ChatRequest.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/ChatRequest.kt @@ -1,6 +1,5 @@ package com.garfiec.librechat.core.model.request -import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.model.FileReference import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -14,8 +13,9 @@ data class ChatRequest( val text: String, val conversationId: String? = null, val parentMessageId: String, - val endpoint: EModelEndpoint, - val endpointType: EModelEndpoint? = null, + val endpoint: String, + val endpointType: String? = null, + val modelDisplayLabel: String? = null, val model: String? = null, @SerialName("agent_id") val agentId: String? = null, val isContinued: Boolean = false, diff --git a/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/ConversationSerializationTest.kt b/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/ConversationSerializationTest.kt index 42fca36..bc8bd86 100644 --- a/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/ConversationSerializationTest.kt +++ b/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/ConversationSerializationTest.kt @@ -25,8 +25,8 @@ class ConversationSerializationTest { conversationId = "conv-002", title = "Full Chat", user = "user-123", - endpoint = EModelEndpoint.OPENAI, - endpointType = EModelEndpoint.AGENTS, + endpoint = "openAI", + endpointType = "agents", model = "gpt-4o", agentId = "agent-abc", assistantId = "asst-def", @@ -94,10 +94,26 @@ class ConversationSerializationTest { @Test fun endpointEnumSerializesToJsonString() { - val original = Conversation(endpoint = EModelEndpoint.ANTHROPIC) + val original = Conversation(endpoint = "anthropic") val encoded = json.encodeToString(Conversation.serializer(), original) assertEquals(true, encoded.contains("\"anthropic\""), "Expected serialized name 'anthropic' in $encoded") val decoded = json.decodeFromString(Conversation.serializer(), encoded) - assertEquals(EModelEndpoint.ANTHROPIC, decoded.endpoint) + assertEquals("anthropic", decoded.endpoint) + } + + @Test + fun conversationWithCustomEndpointRoundTrip() { + val original = Conversation( + conversationId = "conv-custom", + endpoint = "OpenRouter", + endpointType = "custom", + model = "meta-llama/llama-3.3-70b-instruct:free", + ) + val encoded = json.encodeToString(Conversation.serializer(), original) + assertEquals(true, encoded.contains("\"OpenRouter\""), "Expected literal 'OpenRouter' in $encoded") + assertEquals(true, encoded.contains("\"custom\""), "Expected 'custom' endpointType in $encoded") + val decoded = json.decodeFromString(Conversation.serializer(), encoded) + assertEquals("OpenRouter", decoded.endpoint) + assertEquals("custom", decoded.endpointType) } } diff --git a/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/EnumSerializationTest.kt b/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/EnumSerializationTest.kt index 56b7a6f..d7f2307 100644 --- a/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/EnumSerializationTest.kt +++ b/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/EnumSerializationTest.kt @@ -3,11 +3,33 @@ package com.garfiec.librechat.core.model import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class EnumSerializationTest { private val json = Json { ignoreUnknownKeys = true } + @Test + fun fromNameResolvesAllBuiltIns() { + assertEquals(EModelEndpoint.OPENAI, EModelEndpoint.fromName("openAI")) + assertEquals(EModelEndpoint.GOOGLE, EModelEndpoint.fromName("google")) + assertEquals(EModelEndpoint.AZURE_OPENAI, EModelEndpoint.fromName("azureOpenAI")) + assertEquals(EModelEndpoint.ANTHROPIC, EModelEndpoint.fromName("anthropic")) + assertEquals(EModelEndpoint.AGENTS, EModelEndpoint.fromName("agents")) + assertNull(EModelEndpoint.fromName("OpenRouter")) + assertNull(EModelEndpoint.fromName("OPENAI")) + assertNull(EModelEndpoint.fromName("")) + } + + @Test + fun builtInNamesIsAllNineWireFormatStrings() { + val expected = setOf( + "azureOpenAI", "openAI", "google", "anthropic", + "assistants", "azureAssistants", "agents", "custom", "bedrock", + ) + assertEquals(expected, EModelEndpoint.BUILT_IN_NAMES) + } + @Test fun eModelEndpointRoundTrip() { for (value in EModelEndpoint.entries) { diff --git a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointIcons.kt b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointIcons.kt index ddc81dc..2922855 100644 --- a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointIcons.kt +++ b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointIcons.kt @@ -49,17 +49,6 @@ fun endpointIconPainter(endpoint: String?): Painter? = when (endpoint) { * the theme's onSurfaceVariant color. Brand-colored icons (Anthropic, Google, * Azure, Bedrock) should be rendered with [Color.Unspecified] to preserve their colors. */ -fun EModelEndpoint.isMonochromeIcon(): Boolean = when (this) { - EModelEndpoint.OPENAI, - EModelEndpoint.ASSISTANTS, - EModelEndpoint.AGENTS, - -> true - else -> false -} - -/** - * String-based variant of [isMonochromeIcon]. - */ fun isMonochromeEndpointIcon(endpoint: String?): Boolean = when (endpoint) { "openAI", "assistants", "agents" -> true else -> false diff --git a/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModelEndpointDispatchTest.kt b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModelEndpointDispatchTest.kt new file mode 100644 index 0000000..9fb145e --- /dev/null +++ b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModelEndpointDispatchTest.kt @@ -0,0 +1,92 @@ +package com.garfiec.librechat.feature.chat.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test +import java.io.File + +/** + * Static-source regression guard for the issue #60 fix. + * + * Enforces the project convention that every `chatRepository.startChat(` call + * site binds `val dispatch = classifyCurrentEndpoint(...)` and forwards the + * dispatch fields via the literal named arguments `endpointType = dispatch.endpointType`, + * `key = dispatch.key`, and `modelDisplayLabel = dispatch.modelDisplayLabel`. + * + * The greps are intentionally textual: a refactor that renames the local + * (e.g. `d.endpointType`) or inlines the classifier call will fail this test + * and force the author to update the convention deliberately rather than + * silently regressing the wire format back to `endpoint: "agents"`. + */ +class ChatViewModelEndpointDispatchTest { + + /** + * Locates ChatViewModel.kt by walking up from the unit test working directory + * to the module root. Tests run from `feature/chat/`, so the source file sits at + * a known relative path. + */ + private val source: String by lazy { + val candidates = listOf( + File("src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt"), + File("../../src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt"), + File("feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt"), + ) + val found = candidates.firstOrNull { it.exists() } + ?: error("ChatViewModel.kt not found from ${File(".").absolutePath}") + found.readText() + } + + @Test + fun everyStartChatCallPassesEndpointTypeKeyAndModelDisplayLabel() { + val callSites = extractStartChatCallSites(source) + assertThat(callSites).isNotEmpty() + + callSites.forEachIndexed { index, callBlock -> + assertWithMessage("Site $index").that(callBlock).contains("endpointType = dispatch.endpointType") + assertWithMessage("Site $index").that(callBlock).contains("key = dispatch.key") + assertWithMessage("Site $index").that(callBlock).contains("modelDisplayLabel = dispatch.modelDisplayLabel") + } + } + + @Test + fun classifyHelperExistsAndUsesEndpointConfigsFromUiState() { + // The helper centralizes the classifier wiring; if someone removes it, every + // call site silently breaks. Guard the wiring. + assertThat(source).contains("private fun classifyCurrentEndpoint(name: String)") + assertThat(source).contains("EndpointClassifier.classify(name, _uiState.value.endpointConfigs)") + } + + private fun extractStartChatCallSites(text: String): List { + // Strip block + line comments first so doc-comment mentions of + // `chatRepository.startChat(...)` aren't counted as real call sites. + val cleaned = text + .replace(Regex("""/\*[\s\S]*?\*/"""), "") + .replace(Regex("""//[^\n]*"""), "") + val token = "chatRepository.startChat(" + val results = mutableListOf() + var idx = 0 + while (true) { + val start = cleaned.indexOf(token, idx) + if (start < 0) break + // Walk until matching ')' + var depth = 0 + var i = start + token.length - 1 // position of '(' + while (i < cleaned.length) { + when (cleaned[i]) { + '(' -> depth++ + ')' -> { + depth-- + if (depth == 0) { + results.add(cleaned.substring(start, i + 1)) + idx = i + 1 + break + } + } + } + i++ + } + if (depth != 0) error("Unbalanced parens at offset $start") + } + return results + } +} diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt index 73a9616..9516146 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt @@ -12,6 +12,8 @@ import com.garfiec.librechat.core.common.result.getOrNull import com.garfiec.librechat.core.data.datastore.LatexRenderer import com.garfiec.librechat.core.data.datastore.ServerDataStore import com.garfiec.librechat.core.data.datastore.SettingsDataStore +import com.garfiec.librechat.core.data.endpoint.EndpointClassifier +import com.garfiec.librechat.core.data.endpoint.EndpointDispatch import com.garfiec.librechat.core.data.repository.AgentRepository import com.garfiec.librechat.core.data.repository.ChatRepository import com.garfiec.librechat.core.data.repository.ConfigRepository @@ -27,7 +29,6 @@ import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.UserRepository import com.garfiec.librechat.core.data.util.PermissionGate import com.garfiec.librechat.core.model.Attachment -import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.model.Message import com.garfiec.librechat.core.model.Preset import com.garfiec.librechat.core.model.StreamEvent @@ -47,7 +48,6 @@ import com.garfiec.librechat.feature.chat.viewmodel.delegate.InConversationSearc import com.garfiec.librechat.feature.chat.viewmodel.delegate.ModelSelectionDelegate import com.garfiec.librechat.feature.chat.viewmodel.delegate.PlatformDelegateFactory import com.garfiec.librechat.feature.chat.viewmodel.delegate.PresetPromptDelegate -import com.garfiec.librechat.feature.chat.viewmodel.delegate.toSerialName import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -371,9 +371,9 @@ class ChatViewModel( val result = conversationRepository.getConversation(conversationId) val conversation = result.getOrNull() if (conversation != null) { - val endpoint = conversation.endpoint?.toSerialName() + val endpoint = conversation.endpoint val model = conversation.model - val isAgentConversation = endpoint == EModelEndpoint.AGENTS.toSerialName() + val isAgentConversation = endpoint == EndpointConstants.AGENTS val resolvedModel = if (isAgentConversation) { conversation.agentId ?: model } else { @@ -481,6 +481,9 @@ class ChatViewModel( runWhenSendReady { doSendMessage(text) } } + private fun classifyCurrentEndpoint(name: String): EndpointDispatch = + EndpointClassifier.classify(name, _uiState.value.endpointConfigs) + /** * Builds an [EphemeralAgent] from the current UI state (selected MCP servers * and enabled tools). Returns null when there is nothing to send. @@ -597,6 +600,7 @@ class ChatViewModel( } } + val dispatch = classifyCurrentEndpoint(effectiveEndpoint) streamJob?.cancel() streamJob = viewModelScope.launch { collectStreamSafely( @@ -604,6 +608,9 @@ class ChatViewModel( text = messageText, conversationId = conversationId, endpoint = effectiveEndpoint, + endpointType = dispatch.endpointType, + key = dispatch.key, + modelDisplayLabel = dispatch.modelDisplayLabel, model = _uiState.value.selectedModel, parentMessageId = lastMessageId, agentId = effectiveAgentId, @@ -703,6 +710,7 @@ class ChatViewModel( val webSearchEnabled = _uiState.value.modelParameters.webSearch val ephemeralAgent = buildEphemeralAgent() Logger.d { "editUserMessage: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" } + val dispatch = classifyCurrentEndpoint(_uiState.value.selectedEndpoint) streamJob?.cancel() streamJob = viewModelScope.launch { collectStreamSafely( @@ -710,6 +718,9 @@ class ChatViewModel( text = newText, conversationId = _uiState.value.conversationId, endpoint = _uiState.value.selectedEndpoint, + endpointType = dispatch.endpointType, + key = dispatch.key, + modelDisplayLabel = dispatch.modelDisplayLabel, model = _uiState.value.selectedModel, parentMessageId = parentMessageId, agentId = if (isAgent) _uiState.value.selectedModel else null, @@ -735,6 +746,7 @@ class ChatViewModel( val webSearchEnabled = _uiState.value.modelParameters.webSearch val ephemeralAgent = buildEphemeralAgent() Logger.d { "editAiMessage: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" } + val dispatch = classifyCurrentEndpoint(_uiState.value.selectedEndpoint) streamJob?.cancel() streamJob = viewModelScope.launch { messageRepository.updateMessageText(conversationId, aiMessage.messageId, newText) @@ -743,6 +755,9 @@ class ChatViewModel( text = parentUserMessage.text, conversationId = conversationId, endpoint = _uiState.value.selectedEndpoint, + endpointType = dispatch.endpointType, + key = dispatch.key, + modelDisplayLabel = dispatch.modelDisplayLabel, model = _uiState.value.selectedModel, parentMessageId = parentUserMessage.parentMessageId, agentId = if (isAgent) _uiState.value.selectedModel else null, @@ -777,6 +792,7 @@ class ChatViewModel( val webSearchEnabled = _uiState.value.modelParameters.webSearch val ephemeralAgent = buildEphemeralAgent() Logger.d { "regenerateMessage: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" } + val dispatch = classifyCurrentEndpoint(_uiState.value.selectedEndpoint) streamJob?.cancel() streamJob = viewModelScope.launch { collectStreamSafely( @@ -784,6 +800,9 @@ class ChatViewModel( text = parentUserMessage.text, conversationId = _uiState.value.conversationId, endpoint = _uiState.value.selectedEndpoint, + endpointType = dispatch.endpointType, + key = dispatch.key, + modelDisplayLabel = dispatch.modelDisplayLabel, model = _uiState.value.selectedModel, parentMessageId = parentUserMessage.parentMessageId, agentId = if (isAgentRegen) _uiState.value.selectedModel else null, @@ -1187,6 +1206,7 @@ class ChatViewModel( val webSearchEnabled = _uiState.value.modelParameters.webSearch val ephemeralAgent = buildEphemeralAgent() Logger.d { "continueGeneration: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" } + val dispatch = classifyCurrentEndpoint(_uiState.value.selectedEndpoint) streamJob?.cancel() streamJob = viewModelScope.launch { collectStreamSafely( @@ -1194,6 +1214,9 @@ class ChatViewModel( text = parentUserMessage.text, conversationId = _uiState.value.conversationId, endpoint = _uiState.value.selectedEndpoint, + endpointType = dispatch.endpointType, + key = dispatch.key, + modelDisplayLabel = dispatch.modelDisplayLabel, model = _uiState.value.selectedModel, parentMessageId = parentUserMessage.parentMessageId, agentId = if (isAgentContinue) _uiState.value.selectedModel else null, diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/EModelEndpointExtensions.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/EModelEndpointExtensions.kt deleted file mode 100644 index aa24bff..0000000 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/EModelEndpointExtensions.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.garfiec.librechat.feature.chat.viewmodel.delegate - -import com.garfiec.librechat.core.model.EModelEndpoint - -internal fun EModelEndpoint.toSerialName(): String = when (this) { - EModelEndpoint.AZURE_OPENAI -> "azureOpenAI" - EModelEndpoint.OPENAI -> "openAI" - EModelEndpoint.GOOGLE -> "google" - EModelEndpoint.ANTHROPIC -> "anthropic" - EModelEndpoint.ASSISTANTS -> "assistants" - EModelEndpoint.AZURE_ASSISTANTS -> "azureAssistants" - EModelEndpoint.AGENTS -> "agents" - EModelEndpoint.CUSTOM -> "custom" - EModelEndpoint.BEDROCK -> "bedrock" -} diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt index be1666f..d061fa5 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt @@ -5,11 +5,11 @@ import com.garfiec.librechat.core.common.EndpointConstants import com.garfiec.librechat.core.common.ToolConstants import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.data.datastore.SettingsDataStore +import com.garfiec.librechat.core.data.endpoint.EndpointClassifier import com.garfiec.librechat.core.data.repository.AgentRepository import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.McpRepository import com.garfiec.librechat.core.data.util.PermissionGate -import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.model.mcp.McpServer import com.garfiec.librechat.core.model.permissions.Permission import com.garfiec.librechat.core.model.permissions.PermissionType @@ -79,7 +79,7 @@ class ModelSelectionDelegate( // Validate current selection val currentEndpoint = stateHandle.state.selectedEndpoint val currentModel = stateHandle.state.selectedModel - val isAgentSelection = currentEndpoint == EModelEndpoint.AGENTS.toSerialName() + val isAgentSelection = currentEndpoint == EndpointConstants.AGENTS val modelsForEndpoint = filtered[currentEndpoint] val selectionValid = isAgentSelection || (currentModel != null && modelsForEndpoint != null && @@ -93,7 +93,7 @@ class ModelSelectionDelegate( val lastEndpoint = cachedLastUsedEndpoint val lastModel = cachedLastUsedModel if (lastEndpoint != null && lastModel != null) { - val lastIsAgent = lastEndpoint == EModelEndpoint.AGENTS.toSerialName() + val lastIsAgent = lastEndpoint == EndpointConstants.AGENTS val lastModelsForEndpoint = filtered[lastEndpoint] if (lastIsAgent || (lastModelsForEndpoint != null && lastModel in lastModelsForEndpoint)) { stateHandle.update { @@ -208,12 +208,14 @@ class ModelSelectionDelegate( val endpoint = comparison.secondaryEndpoint ?: return null val model = comparison.secondaryModel ?: return null val isAgent = endpoint == EndpointConstants.AGENTS - val resolvedEndpoint = resolveEndpointEnum(endpoint) + val dispatch = EndpointClassifier.classify(endpoint, stateHandle.state.endpointConfigs) val added = AddedConversation( conversationId = stateHandle.state.conversationId, parentMessageId = parentMessageId, - endpoint = resolvedEndpoint, - endpointType = resolvedEndpoint, + endpoint = endpoint, + endpointType = dispatch.endpointType, + modelDisplayLabel = dispatch.modelDisplayLabel, + key = dispatch.key, agentId = if (isAgent) model else null, model = if (isAgent) null else model, ) @@ -239,19 +241,6 @@ class ModelSelectionDelegate( return agentId.contains("____") } - /** - * Resolves an endpoint string (e.g. "openAI") to its [EModelEndpoint] enum value. - * Defaults to [EModelEndpoint.OPENAI] for unrecognized values. - */ - private fun resolveEndpointEnum(endpoint: String): EModelEndpoint { - return try { - EModelEndpoint.entries.firstOrNull { it.toSerialName() == endpoint } - ?: EModelEndpoint.valueOf(endpoint.uppercase()) - } catch (_: IllegalArgumentException) { - EModelEndpoint.OPENAI - } - } - fun loadAgents() { stateHandle.scope.launch { // Skip the fetch entirely when the role denies AGENTS.USE; otherwise diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt index 5b85de3..b3f0793 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt @@ -4,7 +4,6 @@ import co.touchlab.kermit.Logger import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.data.repository.PresetRepository import com.garfiec.librechat.core.data.repository.PromptRepository -import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.model.Preset import com.garfiec.librechat.core.model.PromptGroup import com.garfiec.librechat.feature.chat.model.PresetDisplayData @@ -62,11 +61,7 @@ class PresetPromptDelegate( val state = stateHandle.state val preset = Preset( title = name, - endpoint = try { - EModelEndpoint.valueOf(state.selectedEndpoint.uppercase()) - } catch (_: IllegalArgumentException) { - null - }, + endpoint = state.selectedEndpoint.takeIf { it.isNotBlank() }, model = state.selectedModel, ) stateHandle.scope.launch { @@ -84,7 +79,7 @@ class PresetPromptDelegate( val preset = cachedPresets.find { it.presetId == displayData.presetId } stateHandle.update { copy( - selectedEndpoint = preset?.endpoint?.name?.lowercase() ?: selectedEndpoint, + selectedEndpoint = preset?.endpoint ?: selectedEndpoint, selectedModel = preset?.model ?: selectedModel, ) } @@ -170,7 +165,7 @@ class PresetPromptDelegate( internal fun Preset.toDisplayData() = PresetDisplayData( presetId = presetId, title = title ?: "Untitled Preset", - endpointLabel = endpoint?.name?.lowercase(), + endpointLabel = endpoint, model = model, ) diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/ArchivedConversationDisplayData.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/ArchivedConversationDisplayData.kt index 0afaf1c..f05791b 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/ArchivedConversationDisplayData.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/ArchivedConversationDisplayData.kt @@ -15,7 +15,7 @@ data class ArchivedConversationDisplayData( fun Conversation.toArchivedDisplayData() = ArchivedConversationDisplayData( id = conversationId ?: "", title = title ?: "New Chat", - endpoint = endpoint?.name?.lowercase() ?: "chat", + endpoint = endpoint ?: "chat", model = model, archivedAt = updatedAt, ) diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt index bf1f8b6..a99669e 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt @@ -2,14 +2,13 @@ package com.garfiec.librechat.feature.conversations.components import androidx.compose.runtime.Immutable import com.garfiec.librechat.core.model.Conversation -import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.model.SAVED_TAG @Immutable data class ConversationDisplayData( val conversationId: String, val title: String, - val endpoint: EModelEndpoint?, + val endpoint: String?, val model: String?, val updatedAt: String?, val isBookmarked: Boolean, diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt index b926605..5357dd2 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp import com.garfiec.librechat.core.common.extensions.toInstantOrNull import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.ui.components.endpointIconPainter -import com.garfiec.librechat.core.ui.components.isMonochromeIcon +import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon import com.garfiec.librechat.feature.conversations.resources.* import com.garfiec.librechat.feature.conversations.resources.Res import kotlinx.datetime.TimeZone @@ -61,7 +61,7 @@ fun ConversationItem( verticalAlignment = Alignment.CenterVertically, ) { if (iconPainter != null) { - val isMonochrome = data.endpoint?.isMonochromeIcon() == true + val isMonochrome = isMonochromeEndpointIcon(data.endpoint) Icon( painter = iconPainter, contentDescription = endpointLabel, @@ -179,7 +179,7 @@ private fun Instant.toRelativeTimeString(): String { } } -private fun EModelEndpoint.toDisplayLabel(): String = when (this) { +private fun String.toDisplayLabel(): String = when (EModelEndpoint.fromName(this)) { EModelEndpoint.OPENAI -> "OpenAI" EModelEndpoint.AZURE_OPENAI -> "Azure" EModelEndpoint.GOOGLE -> "Google" @@ -189,4 +189,5 @@ private fun EModelEndpoint.toDisplayLabel(): String = when (this) { EModelEndpoint.AGENTS -> "Agents" EModelEndpoint.CUSTOM -> "Custom" EModelEndpoint.BEDROCK -> "Bedrock" + null -> this } diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/PresetManagerViewModel.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/PresetManagerViewModel.kt index 5869949..0ce99b3 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/PresetManagerViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/PresetManagerViewModel.kt @@ -99,6 +99,6 @@ class PresetManagerViewModel( private fun Preset.toDisplayData() = PresetManagerDisplayData( presetId = presetId, title = title ?: "Untitled Preset", - endpoint = endpoint?.name?.lowercase(), + endpoint = endpoint, model = model, ) diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt index 231fcd1..a0d4f19 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt @@ -53,7 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.garfiec.librechat.core.ui.components.endpointIconPainter -import com.garfiec.librechat.core.ui.components.isMonochromeIcon +import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon import com.garfiec.librechat.shared.resources.Res import com.garfiec.librechat.shared.resources.agents import com.garfiec.librechat.shared.resources.bookmark @@ -400,7 +400,7 @@ private fun DrawerConversationItem( tint = MaterialTheme.colorScheme.primary, ) } else if (iconPainter != null) { - val isMonochrome = data.endpoint?.isMonochromeIcon() == true + val isMonochrome = isMonochromeEndpointIcon(data.endpoint) Icon( painter = iconPainter, contentDescription = null, diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt index 71e749c..b61fd5d 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt @@ -1,7 +1,6 @@ package com.garfiec.librechat.shared.navigation import androidx.compose.runtime.Immutable -import com.garfiec.librechat.core.model.EModelEndpoint /** * Lightweight snapshot of fields DrawerConversationItem actually renders. @@ -12,7 +11,7 @@ data class DrawerConversationDisplayData( val conversationId: String, val title: String, val model: String?, - val endpoint: EModelEndpoint?, + val endpoint: String?, val relativeTime: String, val isActive: Boolean, val isFavorite: Boolean,