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.
This commit is contained in:
parent
4454324b6e
commit
2b44937368
28 changed files with 481 additions and 99 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -25,3 +25,8 @@ local.properties
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local Claude Code working notes (issue repros, scratch captures with secrets)
|
||||||
|
.claude/issue-*/
|
||||||
|
.claude/worktrees/
|
||||||
|
.claude/sync-upstream/
|
||||||
|
|
|
||||||
|
|
@ -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<String, EndpointConfig>): 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,12 +11,22 @@ import kotlin.time.Instant
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
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(
|
fun Conversation.toEntity(): ConversationEntity = ConversationEntity(
|
||||||
conversationId = conversationId ?: "",
|
conversationId = conversationId ?: "",
|
||||||
title = title ?: "New Chat",
|
title = title ?: "New Chat",
|
||||||
user = user ?: "",
|
user = user ?: "",
|
||||||
endpoint = endpoint?.name,
|
endpoint = endpoint,
|
||||||
endpointType = endpointType?.name,
|
endpointType = endpointType,
|
||||||
model = model,
|
model = model,
|
||||||
agentId = agentId,
|
agentId = agentId,
|
||||||
isArchived = isArchived,
|
isArchived = isArchived,
|
||||||
|
|
@ -32,12 +42,8 @@ fun ConversationEntity.toModel(): Conversation = Conversation(
|
||||||
conversationId = conversationId,
|
conversationId = conversationId,
|
||||||
title = title,
|
title = title,
|
||||||
user = user,
|
user = user,
|
||||||
endpoint = endpoint?.let { name ->
|
endpoint = normalizeEndpoint(endpoint),
|
||||||
EModelEndpoint.entries.find { it.name == name }
|
endpointType = normalizeEndpoint(endpointType),
|
||||||
},
|
|
||||||
endpointType = endpointType?.let { name ->
|
|
||||||
EModelEndpoint.entries.find { it.name == name }
|
|
||||||
},
|
|
||||||
model = model,
|
model = model,
|
||||||
agentId = agentId,
|
agentId = agentId,
|
||||||
isArchived = isArchived,
|
isArchived = isArchived,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.garfiec.librechat.core.data.repository
|
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.FileReference
|
||||||
import com.garfiec.librechat.core.model.request.AddedConversation
|
import com.garfiec.librechat.core.model.request.AddedConversation
|
||||||
import com.garfiec.librechat.core.model.request.ChatRequest
|
import com.garfiec.librechat.core.model.request.ChatRequest
|
||||||
|
|
@ -13,6 +12,9 @@ object ChatPayloadBuilder {
|
||||||
text: String,
|
text: String,
|
||||||
conversationId: String?,
|
conversationId: String?,
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
|
endpointType: String? = null,
|
||||||
|
key: String? = null,
|
||||||
|
modelDisplayLabel: String? = null,
|
||||||
model: String?,
|
model: String?,
|
||||||
parentMessageId: String? = null,
|
parentMessageId: String? = null,
|
||||||
agentId: String? = null,
|
agentId: String? = null,
|
||||||
|
|
@ -26,19 +28,16 @@ object ChatPayloadBuilder {
|
||||||
addedConvo: AddedConversation? = null,
|
addedConvo: AddedConversation? = null,
|
||||||
ephemeralAgent: EphemeralAgent? = null,
|
ephemeralAgent: EphemeralAgent? = null,
|
||||||
): ChatRequest {
|
): ChatRequest {
|
||||||
val resolvedEndpoint = try {
|
|
||||||
EModelEndpoint.valueOf(endpoint.uppercase())
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
EModelEndpoint.AGENTS
|
|
||||||
}
|
|
||||||
|
|
||||||
val resolvedParentMessageId = parentMessageId ?: NO_PARENT
|
val resolvedParentMessageId = parentMessageId ?: NO_PARENT
|
||||||
|
|
||||||
return ChatRequest(
|
return ChatRequest(
|
||||||
text = text,
|
text = text,
|
||||||
conversationId = conversationId,
|
conversationId = conversationId,
|
||||||
parentMessageId = resolvedParentMessageId,
|
parentMessageId = resolvedParentMessageId,
|
||||||
endpoint = resolvedEndpoint,
|
endpoint = endpoint,
|
||||||
|
endpointType = endpointType,
|
||||||
|
key = key,
|
||||||
|
modelDisplayLabel = modelDisplayLabel,
|
||||||
model = model,
|
model = model,
|
||||||
agentId = agentId,
|
agentId = agentId,
|
||||||
overrideParentMessageId = overrideParentMessageId,
|
overrideParentMessageId = overrideParentMessageId,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ interface ChatRepository {
|
||||||
text: String,
|
text: String,
|
||||||
conversationId: String?,
|
conversationId: String?,
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
|
endpointType: String? = null,
|
||||||
|
key: String? = null,
|
||||||
|
modelDisplayLabel: String? = null,
|
||||||
model: String?,
|
model: String?,
|
||||||
parentMessageId: String? = null,
|
parentMessageId: String? = null,
|
||||||
agentId: String? = null,
|
agentId: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ class ChatRepositoryImpl(
|
||||||
text: String,
|
text: String,
|
||||||
conversationId: String?,
|
conversationId: String?,
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
|
endpointType: String?,
|
||||||
|
key: String?,
|
||||||
|
modelDisplayLabel: String?,
|
||||||
model: String?,
|
model: String?,
|
||||||
parentMessageId: String?,
|
parentMessageId: String?,
|
||||||
agentId: String?,
|
agentId: String?,
|
||||||
|
|
@ -42,6 +45,9 @@ class ChatRepositoryImpl(
|
||||||
text = text,
|
text = text,
|
||||||
conversationId = conversationId,
|
conversationId = conversationId,
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
|
endpointType = endpointType,
|
||||||
|
key = key,
|
||||||
|
modelDisplayLabel = modelDisplayLabel,
|
||||||
model = model,
|
model = model,
|
||||||
parentMessageId = parentMessageId,
|
parentMessageId = parentMessageId,
|
||||||
agentId = agentId,
|
agentId = agentId,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,8 @@ data class Conversation(
|
||||||
val conversationId: String? = null,
|
val conversationId: String? = null,
|
||||||
val title: String? = "New Chat",
|
val title: String? = "New Chat",
|
||||||
val user: String? = null,
|
val user: String? = null,
|
||||||
val endpoint: EModelEndpoint? = null,
|
val endpoint: String? = null,
|
||||||
val endpointType: EModelEndpoint? = null,
|
val endpointType: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
@SerialName("agent_id") val agentId: String? = null,
|
@SerialName("agent_id") val agentId: String? = null,
|
||||||
@SerialName("assistant_id") val assistantId: String? = null,
|
@SerialName("assistant_id") val assistantId: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -30,5 +30,27 @@ enum class EModelEndpoint {
|
||||||
CUSTOM,
|
CUSTOM,
|
||||||
|
|
||||||
@SerialName("bedrock")
|
@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<String> = 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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ data class Preset(
|
||||||
val user: String? = null,
|
val user: String? = null,
|
||||||
val defaultPreset: Boolean? = null,
|
val defaultPreset: Boolean? = null,
|
||||||
val order: Int? = null,
|
val order: Int? = null,
|
||||||
val endpoint: EModelEndpoint? = null,
|
val endpoint: String? = null,
|
||||||
val endpointType: EModelEndpoint? = null,
|
val endpointType: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
@SerialName("agent_id") val agentId: String? = null,
|
@SerialName("agent_id") val agentId: String? = null,
|
||||||
val temperature: Double? = null,
|
val temperature: Double? = null,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.garfiec.librechat.core.model.request
|
package com.garfiec.librechat.core.model.request
|
||||||
|
|
||||||
import com.garfiec.librechat.core.model.EModelEndpoint
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
|
@ -8,8 +7,10 @@ import kotlinx.serialization.Serializable
|
||||||
data class AddedConversation(
|
data class AddedConversation(
|
||||||
val conversationId: String? = null,
|
val conversationId: String? = null,
|
||||||
val parentMessageId: String? = null,
|
val parentMessageId: String? = null,
|
||||||
val endpoint: EModelEndpoint? = null,
|
val endpoint: String? = null,
|
||||||
val endpointType: EModelEndpoint? = null,
|
val endpointType: String? = null,
|
||||||
|
val modelDisplayLabel: String? = null,
|
||||||
|
val key: String? = null,
|
||||||
@SerialName("agent_id") val agentId: String? = null,
|
@SerialName("agent_id") val agentId: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
val modelLabel: String? = null,
|
val modelLabel: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.garfiec.librechat.core.model.request
|
package com.garfiec.librechat.core.model.request
|
||||||
|
|
||||||
import com.garfiec.librechat.core.model.EModelEndpoint
|
|
||||||
import com.garfiec.librechat.core.model.FileReference
|
import com.garfiec.librechat.core.model.FileReference
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -14,8 +13,9 @@ data class ChatRequest(
|
||||||
val text: String,
|
val text: String,
|
||||||
val conversationId: String? = null,
|
val conversationId: String? = null,
|
||||||
val parentMessageId: String,
|
val parentMessageId: String,
|
||||||
val endpoint: EModelEndpoint,
|
val endpoint: String,
|
||||||
val endpointType: EModelEndpoint? = null,
|
val endpointType: String? = null,
|
||||||
|
val modelDisplayLabel: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
@SerialName("agent_id") val agentId: String? = null,
|
@SerialName("agent_id") val agentId: String? = null,
|
||||||
val isContinued: Boolean = false,
|
val isContinued: Boolean = false,
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ class ConversationSerializationTest {
|
||||||
conversationId = "conv-002",
|
conversationId = "conv-002",
|
||||||
title = "Full Chat",
|
title = "Full Chat",
|
||||||
user = "user-123",
|
user = "user-123",
|
||||||
endpoint = EModelEndpoint.OPENAI,
|
endpoint = "openAI",
|
||||||
endpointType = EModelEndpoint.AGENTS,
|
endpointType = "agents",
|
||||||
model = "gpt-4o",
|
model = "gpt-4o",
|
||||||
agentId = "agent-abc",
|
agentId = "agent-abc",
|
||||||
assistantId = "asst-def",
|
assistantId = "asst-def",
|
||||||
|
|
@ -94,10 +94,26 @@ class ConversationSerializationTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun endpointEnumSerializesToJsonString() {
|
fun endpointEnumSerializesToJsonString() {
|
||||||
val original = Conversation(endpoint = EModelEndpoint.ANTHROPIC)
|
val original = Conversation(endpoint = "anthropic")
|
||||||
val encoded = json.encodeToString(Conversation.serializer(), original)
|
val encoded = json.encodeToString(Conversation.serializer(), original)
|
||||||
assertEquals(true, encoded.contains("\"anthropic\""), "Expected serialized name 'anthropic' in $encoded")
|
assertEquals(true, encoded.contains("\"anthropic\""), "Expected serialized name 'anthropic' in $encoded")
|
||||||
val decoded = json.decodeFromString(Conversation.serializer(), 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,33 @@ package com.garfiec.librechat.core.model
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
class EnumSerializationTest {
|
class EnumSerializationTest {
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
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
|
@Test
|
||||||
fun eModelEndpointRoundTrip() {
|
fun eModelEndpointRoundTrip() {
|
||||||
for (value in EModelEndpoint.entries) {
|
for (value in EModelEndpoint.entries) {
|
||||||
|
|
|
||||||
|
|
@ -49,17 +49,6 @@ fun endpointIconPainter(endpoint: String?): Painter? = when (endpoint) {
|
||||||
* the theme's onSurfaceVariant color. Brand-colored icons (Anthropic, Google,
|
* the theme's onSurfaceVariant color. Brand-colored icons (Anthropic, Google,
|
||||||
* Azure, Bedrock) should be rendered with [Color.Unspecified] to preserve their colors.
|
* 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) {
|
fun isMonochromeEndpointIcon(endpoint: String?): Boolean = when (endpoint) {
|
||||||
"openAI", "assistants", "agents" -> true
|
"openAI", "assistants", "agents" -> true
|
||||||
else -> false
|
else -> false
|
||||||
|
|
|
||||||
|
|
@ -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<String> {
|
||||||
|
// 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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.LatexRenderer
|
||||||
import com.garfiec.librechat.core.data.datastore.ServerDataStore
|
import com.garfiec.librechat.core.data.datastore.ServerDataStore
|
||||||
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
|
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.AgentRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ChatRepository
|
import com.garfiec.librechat.core.data.repository.ChatRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
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.repository.UserRepository
|
||||||
import com.garfiec.librechat.core.data.util.PermissionGate
|
import com.garfiec.librechat.core.data.util.PermissionGate
|
||||||
import com.garfiec.librechat.core.model.Attachment
|
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.Message
|
||||||
import com.garfiec.librechat.core.model.Preset
|
import com.garfiec.librechat.core.model.Preset
|
||||||
import com.garfiec.librechat.core.model.StreamEvent
|
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.ModelSelectionDelegate
|
||||||
import com.garfiec.librechat.feature.chat.viewmodel.delegate.PlatformDelegateFactory
|
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.PresetPromptDelegate
|
||||||
import com.garfiec.librechat.feature.chat.viewmodel.delegate.toSerialName
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -371,9 +371,9 @@ class ChatViewModel(
|
||||||
val result = conversationRepository.getConversation(conversationId)
|
val result = conversationRepository.getConversation(conversationId)
|
||||||
val conversation = result.getOrNull()
|
val conversation = result.getOrNull()
|
||||||
if (conversation != null) {
|
if (conversation != null) {
|
||||||
val endpoint = conversation.endpoint?.toSerialName()
|
val endpoint = conversation.endpoint
|
||||||
val model = conversation.model
|
val model = conversation.model
|
||||||
val isAgentConversation = endpoint == EModelEndpoint.AGENTS.toSerialName()
|
val isAgentConversation = endpoint == EndpointConstants.AGENTS
|
||||||
val resolvedModel = if (isAgentConversation) {
|
val resolvedModel = if (isAgentConversation) {
|
||||||
conversation.agentId ?: model
|
conversation.agentId ?: model
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -481,6 +481,9 @@ class ChatViewModel(
|
||||||
runWhenSendReady { doSendMessage(text) }
|
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
|
* Builds an [EphemeralAgent] from the current UI state (selected MCP servers
|
||||||
* and enabled tools). Returns null when there is nothing to send.
|
* and enabled tools). Returns null when there is nothing to send.
|
||||||
|
|
@ -597,6 +600,7 @@ class ChatViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val dispatch = classifyCurrentEndpoint(effectiveEndpoint)
|
||||||
streamJob?.cancel()
|
streamJob?.cancel()
|
||||||
streamJob = viewModelScope.launch {
|
streamJob = viewModelScope.launch {
|
||||||
collectStreamSafely(
|
collectStreamSafely(
|
||||||
|
|
@ -604,6 +608,9 @@ class ChatViewModel(
|
||||||
text = messageText,
|
text = messageText,
|
||||||
conversationId = conversationId,
|
conversationId = conversationId,
|
||||||
endpoint = effectiveEndpoint,
|
endpoint = effectiveEndpoint,
|
||||||
|
endpointType = dispatch.endpointType,
|
||||||
|
key = dispatch.key,
|
||||||
|
modelDisplayLabel = dispatch.modelDisplayLabel,
|
||||||
model = _uiState.value.selectedModel,
|
model = _uiState.value.selectedModel,
|
||||||
parentMessageId = lastMessageId,
|
parentMessageId = lastMessageId,
|
||||||
agentId = effectiveAgentId,
|
agentId = effectiveAgentId,
|
||||||
|
|
@ -703,6 +710,7 @@ class ChatViewModel(
|
||||||
val webSearchEnabled = _uiState.value.modelParameters.webSearch
|
val webSearchEnabled = _uiState.value.modelParameters.webSearch
|
||||||
val ephemeralAgent = buildEphemeralAgent()
|
val ephemeralAgent = buildEphemeralAgent()
|
||||||
Logger.d { "editUserMessage: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" }
|
Logger.d { "editUserMessage: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" }
|
||||||
|
val dispatch = classifyCurrentEndpoint(_uiState.value.selectedEndpoint)
|
||||||
streamJob?.cancel()
|
streamJob?.cancel()
|
||||||
streamJob = viewModelScope.launch {
|
streamJob = viewModelScope.launch {
|
||||||
collectStreamSafely(
|
collectStreamSafely(
|
||||||
|
|
@ -710,6 +718,9 @@ class ChatViewModel(
|
||||||
text = newText,
|
text = newText,
|
||||||
conversationId = _uiState.value.conversationId,
|
conversationId = _uiState.value.conversationId,
|
||||||
endpoint = _uiState.value.selectedEndpoint,
|
endpoint = _uiState.value.selectedEndpoint,
|
||||||
|
endpointType = dispatch.endpointType,
|
||||||
|
key = dispatch.key,
|
||||||
|
modelDisplayLabel = dispatch.modelDisplayLabel,
|
||||||
model = _uiState.value.selectedModel,
|
model = _uiState.value.selectedModel,
|
||||||
parentMessageId = parentMessageId,
|
parentMessageId = parentMessageId,
|
||||||
agentId = if (isAgent) _uiState.value.selectedModel else null,
|
agentId = if (isAgent) _uiState.value.selectedModel else null,
|
||||||
|
|
@ -735,6 +746,7 @@ class ChatViewModel(
|
||||||
val webSearchEnabled = _uiState.value.modelParameters.webSearch
|
val webSearchEnabled = _uiState.value.modelParameters.webSearch
|
||||||
val ephemeralAgent = buildEphemeralAgent()
|
val ephemeralAgent = buildEphemeralAgent()
|
||||||
Logger.d { "editAiMessage: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" }
|
Logger.d { "editAiMessage: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" }
|
||||||
|
val dispatch = classifyCurrentEndpoint(_uiState.value.selectedEndpoint)
|
||||||
streamJob?.cancel()
|
streamJob?.cancel()
|
||||||
streamJob = viewModelScope.launch {
|
streamJob = viewModelScope.launch {
|
||||||
messageRepository.updateMessageText(conversationId, aiMessage.messageId, newText)
|
messageRepository.updateMessageText(conversationId, aiMessage.messageId, newText)
|
||||||
|
|
@ -743,6 +755,9 @@ class ChatViewModel(
|
||||||
text = parentUserMessage.text,
|
text = parentUserMessage.text,
|
||||||
conversationId = conversationId,
|
conversationId = conversationId,
|
||||||
endpoint = _uiState.value.selectedEndpoint,
|
endpoint = _uiState.value.selectedEndpoint,
|
||||||
|
endpointType = dispatch.endpointType,
|
||||||
|
key = dispatch.key,
|
||||||
|
modelDisplayLabel = dispatch.modelDisplayLabel,
|
||||||
model = _uiState.value.selectedModel,
|
model = _uiState.value.selectedModel,
|
||||||
parentMessageId = parentUserMessage.parentMessageId,
|
parentMessageId = parentUserMessage.parentMessageId,
|
||||||
agentId = if (isAgent) _uiState.value.selectedModel else null,
|
agentId = if (isAgent) _uiState.value.selectedModel else null,
|
||||||
|
|
@ -777,6 +792,7 @@ class ChatViewModel(
|
||||||
val webSearchEnabled = _uiState.value.modelParameters.webSearch
|
val webSearchEnabled = _uiState.value.modelParameters.webSearch
|
||||||
val ephemeralAgent = buildEphemeralAgent()
|
val ephemeralAgent = buildEphemeralAgent()
|
||||||
Logger.d { "regenerateMessage: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" }
|
Logger.d { "regenerateMessage: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" }
|
||||||
|
val dispatch = classifyCurrentEndpoint(_uiState.value.selectedEndpoint)
|
||||||
streamJob?.cancel()
|
streamJob?.cancel()
|
||||||
streamJob = viewModelScope.launch {
|
streamJob = viewModelScope.launch {
|
||||||
collectStreamSafely(
|
collectStreamSafely(
|
||||||
|
|
@ -784,6 +800,9 @@ class ChatViewModel(
|
||||||
text = parentUserMessage.text,
|
text = parentUserMessage.text,
|
||||||
conversationId = _uiState.value.conversationId,
|
conversationId = _uiState.value.conversationId,
|
||||||
endpoint = _uiState.value.selectedEndpoint,
|
endpoint = _uiState.value.selectedEndpoint,
|
||||||
|
endpointType = dispatch.endpointType,
|
||||||
|
key = dispatch.key,
|
||||||
|
modelDisplayLabel = dispatch.modelDisplayLabel,
|
||||||
model = _uiState.value.selectedModel,
|
model = _uiState.value.selectedModel,
|
||||||
parentMessageId = parentUserMessage.parentMessageId,
|
parentMessageId = parentUserMessage.parentMessageId,
|
||||||
agentId = if (isAgentRegen) _uiState.value.selectedModel else null,
|
agentId = if (isAgentRegen) _uiState.value.selectedModel else null,
|
||||||
|
|
@ -1187,6 +1206,7 @@ class ChatViewModel(
|
||||||
val webSearchEnabled = _uiState.value.modelParameters.webSearch
|
val webSearchEnabled = _uiState.value.modelParameters.webSearch
|
||||||
val ephemeralAgent = buildEphemeralAgent()
|
val ephemeralAgent = buildEphemeralAgent()
|
||||||
Logger.d { "continueGeneration: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" }
|
Logger.d { "continueGeneration: webSearch=$webSearchEnabled, ephemeralAgent=$ephemeralAgent" }
|
||||||
|
val dispatch = classifyCurrentEndpoint(_uiState.value.selectedEndpoint)
|
||||||
streamJob?.cancel()
|
streamJob?.cancel()
|
||||||
streamJob = viewModelScope.launch {
|
streamJob = viewModelScope.launch {
|
||||||
collectStreamSafely(
|
collectStreamSafely(
|
||||||
|
|
@ -1194,6 +1214,9 @@ class ChatViewModel(
|
||||||
text = parentUserMessage.text,
|
text = parentUserMessage.text,
|
||||||
conversationId = _uiState.value.conversationId,
|
conversationId = _uiState.value.conversationId,
|
||||||
endpoint = _uiState.value.selectedEndpoint,
|
endpoint = _uiState.value.selectedEndpoint,
|
||||||
|
endpointType = dispatch.endpointType,
|
||||||
|
key = dispatch.key,
|
||||||
|
modelDisplayLabel = dispatch.modelDisplayLabel,
|
||||||
model = _uiState.value.selectedModel,
|
model = _uiState.value.selectedModel,
|
||||||
parentMessageId = parentUserMessage.parentMessageId,
|
parentMessageId = parentUserMessage.parentMessageId,
|
||||||
agentId = if (isAgentContinue) _uiState.value.selectedModel else null,
|
agentId = if (isAgentContinue) _uiState.value.selectedModel else 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"
|
|
||||||
}
|
|
||||||
|
|
@ -5,11 +5,11 @@ import com.garfiec.librechat.core.common.EndpointConstants
|
||||||
import com.garfiec.librechat.core.common.ToolConstants
|
import com.garfiec.librechat.core.common.ToolConstants
|
||||||
import com.garfiec.librechat.core.common.result.Result
|
import com.garfiec.librechat.core.common.result.Result
|
||||||
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
|
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.AgentRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
||||||
import com.garfiec.librechat.core.data.repository.McpRepository
|
import com.garfiec.librechat.core.data.repository.McpRepository
|
||||||
import com.garfiec.librechat.core.data.util.PermissionGate
|
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.mcp.McpServer
|
||||||
import com.garfiec.librechat.core.model.permissions.Permission
|
import com.garfiec.librechat.core.model.permissions.Permission
|
||||||
import com.garfiec.librechat.core.model.permissions.PermissionType
|
import com.garfiec.librechat.core.model.permissions.PermissionType
|
||||||
|
|
@ -79,7 +79,7 @@ class ModelSelectionDelegate(
|
||||||
// Validate current selection
|
// Validate current selection
|
||||||
val currentEndpoint = stateHandle.state.selectedEndpoint
|
val currentEndpoint = stateHandle.state.selectedEndpoint
|
||||||
val currentModel = stateHandle.state.selectedModel
|
val currentModel = stateHandle.state.selectedModel
|
||||||
val isAgentSelection = currentEndpoint == EModelEndpoint.AGENTS.toSerialName()
|
val isAgentSelection = currentEndpoint == EndpointConstants.AGENTS
|
||||||
val modelsForEndpoint = filtered[currentEndpoint]
|
val modelsForEndpoint = filtered[currentEndpoint]
|
||||||
val selectionValid = isAgentSelection || (currentModel != null &&
|
val selectionValid = isAgentSelection || (currentModel != null &&
|
||||||
modelsForEndpoint != null &&
|
modelsForEndpoint != null &&
|
||||||
|
|
@ -93,7 +93,7 @@ class ModelSelectionDelegate(
|
||||||
val lastEndpoint = cachedLastUsedEndpoint
|
val lastEndpoint = cachedLastUsedEndpoint
|
||||||
val lastModel = cachedLastUsedModel
|
val lastModel = cachedLastUsedModel
|
||||||
if (lastEndpoint != null && lastModel != null) {
|
if (lastEndpoint != null && lastModel != null) {
|
||||||
val lastIsAgent = lastEndpoint == EModelEndpoint.AGENTS.toSerialName()
|
val lastIsAgent = lastEndpoint == EndpointConstants.AGENTS
|
||||||
val lastModelsForEndpoint = filtered[lastEndpoint]
|
val lastModelsForEndpoint = filtered[lastEndpoint]
|
||||||
if (lastIsAgent || (lastModelsForEndpoint != null && lastModel in lastModelsForEndpoint)) {
|
if (lastIsAgent || (lastModelsForEndpoint != null && lastModel in lastModelsForEndpoint)) {
|
||||||
stateHandle.update {
|
stateHandle.update {
|
||||||
|
|
@ -208,12 +208,14 @@ class ModelSelectionDelegate(
|
||||||
val endpoint = comparison.secondaryEndpoint ?: return null
|
val endpoint = comparison.secondaryEndpoint ?: return null
|
||||||
val model = comparison.secondaryModel ?: return null
|
val model = comparison.secondaryModel ?: return null
|
||||||
val isAgent = endpoint == EndpointConstants.AGENTS
|
val isAgent = endpoint == EndpointConstants.AGENTS
|
||||||
val resolvedEndpoint = resolveEndpointEnum(endpoint)
|
val dispatch = EndpointClassifier.classify(endpoint, stateHandle.state.endpointConfigs)
|
||||||
val added = AddedConversation(
|
val added = AddedConversation(
|
||||||
conversationId = stateHandle.state.conversationId,
|
conversationId = stateHandle.state.conversationId,
|
||||||
parentMessageId = parentMessageId,
|
parentMessageId = parentMessageId,
|
||||||
endpoint = resolvedEndpoint,
|
endpoint = endpoint,
|
||||||
endpointType = resolvedEndpoint,
|
endpointType = dispatch.endpointType,
|
||||||
|
modelDisplayLabel = dispatch.modelDisplayLabel,
|
||||||
|
key = dispatch.key,
|
||||||
agentId = if (isAgent) model else null,
|
agentId = if (isAgent) model else null,
|
||||||
model = if (isAgent) null else model,
|
model = if (isAgent) null else model,
|
||||||
)
|
)
|
||||||
|
|
@ -239,19 +241,6 @@ class ModelSelectionDelegate(
|
||||||
return agentId.contains("____")
|
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() {
|
fun loadAgents() {
|
||||||
stateHandle.scope.launch {
|
stateHandle.scope.launch {
|
||||||
// Skip the fetch entirely when the role denies AGENTS.USE; otherwise
|
// Skip the fetch entirely when the role denies AGENTS.USE; otherwise
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import co.touchlab.kermit.Logger
|
||||||
import com.garfiec.librechat.core.common.result.Result
|
import com.garfiec.librechat.core.common.result.Result
|
||||||
import com.garfiec.librechat.core.data.repository.PresetRepository
|
import com.garfiec.librechat.core.data.repository.PresetRepository
|
||||||
import com.garfiec.librechat.core.data.repository.PromptRepository
|
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.Preset
|
||||||
import com.garfiec.librechat.core.model.PromptGroup
|
import com.garfiec.librechat.core.model.PromptGroup
|
||||||
import com.garfiec.librechat.feature.chat.model.PresetDisplayData
|
import com.garfiec.librechat.feature.chat.model.PresetDisplayData
|
||||||
|
|
@ -62,11 +61,7 @@ class PresetPromptDelegate(
|
||||||
val state = stateHandle.state
|
val state = stateHandle.state
|
||||||
val preset = Preset(
|
val preset = Preset(
|
||||||
title = name,
|
title = name,
|
||||||
endpoint = try {
|
endpoint = state.selectedEndpoint.takeIf { it.isNotBlank() },
|
||||||
EModelEndpoint.valueOf(state.selectedEndpoint.uppercase())
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
model = state.selectedModel,
|
model = state.selectedModel,
|
||||||
)
|
)
|
||||||
stateHandle.scope.launch {
|
stateHandle.scope.launch {
|
||||||
|
|
@ -84,7 +79,7 @@ class PresetPromptDelegate(
|
||||||
val preset = cachedPresets.find { it.presetId == displayData.presetId }
|
val preset = cachedPresets.find { it.presetId == displayData.presetId }
|
||||||
stateHandle.update {
|
stateHandle.update {
|
||||||
copy(
|
copy(
|
||||||
selectedEndpoint = preset?.endpoint?.name?.lowercase() ?: selectedEndpoint,
|
selectedEndpoint = preset?.endpoint ?: selectedEndpoint,
|
||||||
selectedModel = preset?.model ?: selectedModel,
|
selectedModel = preset?.model ?: selectedModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +165,7 @@ class PresetPromptDelegate(
|
||||||
internal fun Preset.toDisplayData() = PresetDisplayData(
|
internal fun Preset.toDisplayData() = PresetDisplayData(
|
||||||
presetId = presetId,
|
presetId = presetId,
|
||||||
title = title ?: "Untitled Preset",
|
title = title ?: "Untitled Preset",
|
||||||
endpointLabel = endpoint?.name?.lowercase(),
|
endpointLabel = endpoint,
|
||||||
model = model,
|
model = model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ data class ArchivedConversationDisplayData(
|
||||||
fun Conversation.toArchivedDisplayData() = ArchivedConversationDisplayData(
|
fun Conversation.toArchivedDisplayData() = ArchivedConversationDisplayData(
|
||||||
id = conversationId ?: "",
|
id = conversationId ?: "",
|
||||||
title = title ?: "New Chat",
|
title = title ?: "New Chat",
|
||||||
endpoint = endpoint?.name?.lowercase() ?: "chat",
|
endpoint = endpoint ?: "chat",
|
||||||
model = model,
|
model = model,
|
||||||
archivedAt = updatedAt,
|
archivedAt = updatedAt,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ package com.garfiec.librechat.feature.conversations.components
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.garfiec.librechat.core.model.Conversation
|
import com.garfiec.librechat.core.model.Conversation
|
||||||
import com.garfiec.librechat.core.model.EModelEndpoint
|
|
||||||
import com.garfiec.librechat.core.model.SAVED_TAG
|
import com.garfiec.librechat.core.model.SAVED_TAG
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class ConversationDisplayData(
|
data class ConversationDisplayData(
|
||||||
val conversationId: String,
|
val conversationId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val endpoint: EModelEndpoint?,
|
val endpoint: String?,
|
||||||
val model: String?,
|
val model: String?,
|
||||||
val updatedAt: String?,
|
val updatedAt: String?,
|
||||||
val isBookmarked: Boolean,
|
val isBookmarked: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
|
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
|
||||||
import com.garfiec.librechat.core.model.EModelEndpoint
|
import com.garfiec.librechat.core.model.EModelEndpoint
|
||||||
import com.garfiec.librechat.core.ui.components.endpointIconPainter
|
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.*
|
||||||
import com.garfiec.librechat.feature.conversations.resources.Res
|
import com.garfiec.librechat.feature.conversations.resources.Res
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
|
@ -61,7 +61,7 @@ fun ConversationItem(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (iconPainter != null) {
|
if (iconPainter != null) {
|
||||||
val isMonochrome = data.endpoint?.isMonochromeIcon() == true
|
val isMonochrome = isMonochromeEndpointIcon(data.endpoint)
|
||||||
Icon(
|
Icon(
|
||||||
painter = iconPainter,
|
painter = iconPainter,
|
||||||
contentDescription = endpointLabel,
|
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.OPENAI -> "OpenAI"
|
||||||
EModelEndpoint.AZURE_OPENAI -> "Azure"
|
EModelEndpoint.AZURE_OPENAI -> "Azure"
|
||||||
EModelEndpoint.GOOGLE -> "Google"
|
EModelEndpoint.GOOGLE -> "Google"
|
||||||
|
|
@ -189,4 +189,5 @@ private fun EModelEndpoint.toDisplayLabel(): String = when (this) {
|
||||||
EModelEndpoint.AGENTS -> "Agents"
|
EModelEndpoint.AGENTS -> "Agents"
|
||||||
EModelEndpoint.CUSTOM -> "Custom"
|
EModelEndpoint.CUSTOM -> "Custom"
|
||||||
EModelEndpoint.BEDROCK -> "Bedrock"
|
EModelEndpoint.BEDROCK -> "Bedrock"
|
||||||
|
null -> this
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,6 @@ class PresetManagerViewModel(
|
||||||
private fun Preset.toDisplayData() = PresetManagerDisplayData(
|
private fun Preset.toDisplayData() = PresetManagerDisplayData(
|
||||||
presetId = presetId,
|
presetId = presetId,
|
||||||
title = title ?: "Untitled Preset",
|
title = title ?: "Untitled Preset",
|
||||||
endpoint = endpoint?.name?.lowercase(),
|
endpoint = endpoint,
|
||||||
model = model,
|
model = model,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.garfiec.librechat.core.ui.components.endpointIconPainter
|
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.Res
|
||||||
import com.garfiec.librechat.shared.resources.agents
|
import com.garfiec.librechat.shared.resources.agents
|
||||||
import com.garfiec.librechat.shared.resources.bookmark
|
import com.garfiec.librechat.shared.resources.bookmark
|
||||||
|
|
@ -400,7 +400,7 @@ private fun DrawerConversationItem(
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
} else if (iconPainter != null) {
|
} else if (iconPainter != null) {
|
||||||
val isMonochrome = data.endpoint?.isMonochromeIcon() == true
|
val isMonochrome = isMonochromeEndpointIcon(data.endpoint)
|
||||||
Icon(
|
Icon(
|
||||||
painter = iconPainter,
|
painter = iconPainter,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.garfiec.librechat.shared.navigation
|
package com.garfiec.librechat.shared.navigation
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.garfiec.librechat.core.model.EModelEndpoint
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight snapshot of fields DrawerConversationItem actually renders.
|
* Lightweight snapshot of fields DrawerConversationItem actually renders.
|
||||||
|
|
@ -12,7 +11,7 @@ data class DrawerConversationDisplayData(
|
||||||
val conversationId: String,
|
val conversationId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val model: String?,
|
val model: String?,
|
||||||
val endpoint: EModelEndpoint?,
|
val endpoint: String?,
|
||||||
val relativeTime: String,
|
val relativeTime: String,
|
||||||
val isActive: Boolean,
|
val isActive: Boolean,
|
||||||
val isFavorite: Boolean,
|
val isFavorite: Boolean,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue