Merge pull request #67 from garfiec/fix/issue-60-custom-endpoint-payload
fix(chat): wire format for custom and user_provided endpoints (#60)
This commit is contained in:
commit
a7477a9bc7
33 changed files with 1236 additions and 111 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,620 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 4,
|
||||
"identityHash": "b4117797915eafc13b5bd956677f83bf",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "conversations",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "conversationId",
|
||||
"columnName": "conversationId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "user",
|
||||
"columnName": "user",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "endpoint",
|
||||
"columnName": "endpoint",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "endpointType",
|
||||
"columnName": "endpointType",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "model",
|
||||
"columnName": "model",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "agentId",
|
||||
"columnName": "agentId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isArchived",
|
||||
"columnName": "isArchived",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconURL",
|
||||
"columnName": "iconURL",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "greeting",
|
||||
"columnName": "greeting",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "modelParams",
|
||||
"columnName": "modelParams",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncedAt",
|
||||
"columnName": "lastSyncedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"conversationId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_conversations_isArchived_updatedAt",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"isArchived",
|
||||
"updatedAt"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived_updatedAt` ON `${TABLE_NAME}` (`isArchived`, `updatedAt`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "conversationId",
|
||||
"columnName": "conversationId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "parentMessageId",
|
||||
"columnName": "parentMessageId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isCreatedByUser",
|
||||
"columnName": "isCreatedByUser",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "model",
|
||||
"columnName": "model",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "endpoint",
|
||||
"columnName": "endpoint",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconURL",
|
||||
"columnName": "iconURL",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "unfinished",
|
||||
"columnName": "unfinished",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "error",
|
||||
"columnName": "error",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "finishReason",
|
||||
"columnName": "finishReason",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tokenCount",
|
||||
"columnName": "tokenCount",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "feedback",
|
||||
"columnName": "feedback",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "files",
|
||||
"columnName": "files",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "metadata",
|
||||
"columnName": "metadata",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_conversationId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"conversationId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_parentMessageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"parentMessageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_conversationId_createdAt",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"conversationId",
|
||||
"createdAt"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "files",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "fileId",
|
||||
"columnName": "fileId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "user",
|
||||
"columnName": "user",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "conversationId",
|
||||
"columnName": "conversationId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filepath",
|
||||
"columnName": "filepath",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bytes",
|
||||
"columnName": "bytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "width",
|
||||
"columnName": "width",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "height",
|
||||
"columnName": "height",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localPath",
|
||||
"columnName": "localPath",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"fileId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_files_conversationId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"conversationId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_files_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "agents",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "avatar",
|
||||
"columnName": "avatar",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "provider",
|
||||
"columnName": "provider",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "model",
|
||||
"columnName": "model",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "category",
|
||||
"columnName": "category",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "authorName",
|
||||
"columnName": "authorName",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPromoted",
|
||||
"columnName": "isPromoted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "conversationStarters",
|
||||
"columnName": "conversationStarters",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tools",
|
||||
"columnName": "tools",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "presets",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "presetId",
|
||||
"columnName": "presetId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "endpoint",
|
||||
"columnName": "endpoint",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "model",
|
||||
"columnName": "model",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "params",
|
||||
"columnName": "params",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"presetId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "conversation_tags",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tag",
|
||||
"columnName": "tag",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "user",
|
||||
"columnName": "user",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "count",
|
||||
"columnName": "count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_conversation_tags_tag_user",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"tag",
|
||||
"user"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversation_id` TEXT NOT NULL, `text` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`conversation_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "conversationId",
|
||||
"columnName": "conversation_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"conversation_id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b4117797915eafc13b5bd956677f83bf')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,10 @@ import android.content.ContentValues
|
|||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.garfiec.librechat.core.data.db.migration.MIGRATION_3_4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Rule
|
||||
|
|
@ -13,12 +15,9 @@ import org.junit.Test
|
|||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Validates Room auto-migrations (v1→v2→v3) preserve all entity data
|
||||
* including complex JSON-serialized fields.
|
||||
*
|
||||
* Strategy: Create a v1 database, populate all 6 v1 entity types with
|
||||
* realistic data (including JSON columns), run migrations to v3,
|
||||
* verify every row and field survived intact.
|
||||
* Validates Room migrations preserve entity data (auto v1→v2→v3) and that
|
||||
* the manual v3→v4 normalization rewrites legacy enum-name endpoint values
|
||||
* to their wire-format counterparts.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomMigrationTest {
|
||||
|
|
@ -223,7 +222,7 @@ class RoomMigrationTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun migrateV1ToV3_viaRoomApi_entitiesReadable() {
|
||||
fun migrateV1ToV4_viaRoomApi_entitiesReadable() {
|
||||
// Create and populate a v1 database
|
||||
val db = helper.createDatabase(testDbName, 1)
|
||||
db.insert(
|
||||
|
|
@ -242,13 +241,15 @@ class RoomMigrationTest {
|
|||
)
|
||||
db.close()
|
||||
|
||||
// Open with Room (auto-runs migrations)
|
||||
// Open with Room (auto-runs auto-migrations 1→2→3 and the manual 3→4)
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val roomDb = Room.databaseBuilder(
|
||||
context,
|
||||
LibreChatDatabase::class.java,
|
||||
testDbName,
|
||||
).build()
|
||||
)
|
||||
.addMigrations(MIGRATION_3_4)
|
||||
.build()
|
||||
|
||||
// Verify we can read the migrated data via DAO
|
||||
val conversation = runBlocking {
|
||||
|
|
@ -259,4 +260,146 @@ class RoomMigrationTest {
|
|||
|
||||
roomDb.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-#67 builds wrote Conversation.endpoint and endpointType as the
|
||||
* Kotlin enum `.name`. The v3→v4 migration rewrites those rows to the
|
||||
* wire-format SerialName so ConversationMapper can drop its read-side shim.
|
||||
*/
|
||||
@Test
|
||||
fun migrateV3ToV4_normalizesLegacyEndpointEnumNames() {
|
||||
val db = helper.createDatabase(testDbName, 3)
|
||||
|
||||
insertLegacyConversation(db, id = "legacy-openai", endpoint = "OPENAI", endpointType = "OPENAI")
|
||||
insertLegacyConversation(db, id = "legacy-azure", endpoint = "AZURE_OPENAI", endpointType = "AZURE_OPENAI")
|
||||
insertLegacyConversation(db, id = "legacy-anthro", endpoint = "ANTHROPIC", endpointType = null)
|
||||
insertLegacyConversation(db, id = "legacy-google", endpoint = "GOOGLE", endpointType = "GOOGLE")
|
||||
insertLegacyConversation(db, id = "legacy-bedrock", endpoint = "BEDROCK", endpointType = "BEDROCK")
|
||||
insertLegacyConversation(db, id = "legacy-assist", endpoint = "ASSISTANTS", endpointType = "ASSISTANTS")
|
||||
insertLegacyConversation(db, id = "legacy-azassist", endpoint = "AZURE_ASSISTANTS", endpointType = "AZURE_ASSISTANTS")
|
||||
insertLegacyConversation(db, id = "legacy-custom", endpoint = "CUSTOM", endpointType = "CUSTOM")
|
||||
|
||||
// Idempotency: already-wire-format rows are untouched
|
||||
insertLegacyConversation(db, id = "post-fix", endpoint = "openAI", endpointType = "openAI")
|
||||
|
||||
// Custom endpoint name passes through (the issue #60 case)
|
||||
insertLegacyConversation(db, id = "custom-name", endpoint = "OpenRouter", endpointType = "custom")
|
||||
|
||||
// Null endpoints unaffected
|
||||
insertLegacyConversation(db, id = "null-ep", endpoint = null, endpointType = null)
|
||||
|
||||
db.close()
|
||||
|
||||
val migrated = helper.runMigrationsAndValidate(testDbName, 4, true, MIGRATION_3_4)
|
||||
|
||||
assertEndpointPair(migrated, "legacy-openai", endpoint = "openAI", endpointType = "openAI")
|
||||
assertEndpointPair(migrated, "legacy-azure", endpoint = "azureOpenAI", endpointType = "azureOpenAI")
|
||||
assertEndpointPair(migrated, "legacy-anthro", endpoint = "anthropic", endpointType = null)
|
||||
assertEndpointPair(migrated, "legacy-google", endpoint = "google", endpointType = "google")
|
||||
assertEndpointPair(migrated, "legacy-bedrock", endpoint = "bedrock", endpointType = "bedrock")
|
||||
assertEndpointPair(migrated, "legacy-assist", endpoint = "assistants", endpointType = "assistants")
|
||||
assertEndpointPair(migrated, "legacy-azassist", endpoint = "azureAssistants", endpointType = "azureAssistants")
|
||||
assertEndpointPair(migrated, "legacy-custom", endpoint = "custom", endpointType = "custom")
|
||||
assertEndpointPair(migrated, "post-fix", endpoint = "openAI", endpointType = "openAI")
|
||||
assertEndpointPair(migrated, "custom-name", endpoint = "OpenRouter", endpointType = "custom")
|
||||
assertEndpointPair(migrated, "null-ep", endpoint = null, endpointType = null)
|
||||
|
||||
migrated.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-#67 ChatPayloadBuilder silently coerced any unknown endpoint name
|
||||
* (OpenRouter, Deepseek, xAI, …) to EModelEndpoint.AGENTS, so a legacy
|
||||
* row with `endpoint = "AGENTS"` may have originated from any custom
|
||||
* endpoint and the original name is unrecoverable.
|
||||
*
|
||||
* The v3→v4 migration rewrites "AGENTS" → "agents" — same lossy outcome
|
||||
* the read-side shim already produced. This test pins that contract so a
|
||||
* future "smart" migration can't silently change the cached/fresh mismatch
|
||||
* behavior without an explicit decision.
|
||||
*/
|
||||
@Test
|
||||
fun migrateV3ToV4_legacyAgentsRowMapsToLowercaseAgents_lossyByDesign() {
|
||||
val db = helper.createDatabase(testDbName, 3)
|
||||
insertLegacyConversation(db, id = "lossy-agents", endpoint = "AGENTS", endpointType = "AGENTS")
|
||||
db.close()
|
||||
|
||||
val migrated = helper.runMigrationsAndValidate(testDbName, 4, true, MIGRATION_3_4)
|
||||
|
||||
assertEndpointPair(migrated, "lossy-agents", endpoint = "agents", endpointType = "agents")
|
||||
|
||||
migrated.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* `presets` table is migrated defensively. PresetDao currently has no
|
||||
* production callers (PresetRepositoryImpl is API-only), so the table is
|
||||
* empty in the field. Test exists so the symmetry is enforced if a future
|
||||
* change starts persisting presets.
|
||||
*/
|
||||
@Test
|
||||
fun migrateV3ToV4_normalizesLegacyPresetEndpoint() {
|
||||
val db = helper.createDatabase(testDbName, 3)
|
||||
db.execSQL(
|
||||
"INSERT INTO presets (presetId, title, endpoint, model, isDefault, `order`, params, createdAt, updatedAt) " +
|
||||
"VALUES ('preset-legacy', 'Legacy', 'OPENAI', 'gpt-4o', 0, 0, '{}', 1700000000000, 1700000000000)",
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO presets (presetId, title, endpoint, model, isDefault, `order`, params, createdAt, updatedAt) " +
|
||||
"VALUES ('preset-custom', 'Custom', 'OpenRouter', 'llama', 0, 1, '{}', 1700000000000, 1700000000000)",
|
||||
)
|
||||
db.close()
|
||||
|
||||
val migrated = helper.runMigrationsAndValidate(testDbName, 4, true, MIGRATION_3_4)
|
||||
|
||||
migrated.query("SELECT endpoint FROM presets WHERE presetId = 'preset-legacy'").use { c ->
|
||||
assertThat(c.moveToFirst()).isTrue()
|
||||
assertThat(c.getString(0)).isEqualTo("openAI")
|
||||
}
|
||||
migrated.query("SELECT endpoint FROM presets WHERE presetId = 'preset-custom'").use { c ->
|
||||
assertThat(c.moveToFirst()).isTrue()
|
||||
assertThat(c.getString(0)).isEqualTo("OpenRouter")
|
||||
}
|
||||
|
||||
migrated.close()
|
||||
}
|
||||
|
||||
private fun insertLegacyConversation(
|
||||
db: SupportSQLiteDatabase,
|
||||
id: String,
|
||||
endpoint: String?,
|
||||
endpointType: String?,
|
||||
) {
|
||||
db.insert(
|
||||
"conversations",
|
||||
SQLiteDatabase.CONFLICT_REPLACE,
|
||||
ContentValues().apply {
|
||||
put("conversationId", id)
|
||||
put("title", "Title $id")
|
||||
put("user", "user-001")
|
||||
put("endpoint", endpoint)
|
||||
put("endpointType", endpointType)
|
||||
put("isArchived", 0)
|
||||
put("tags", "[]")
|
||||
put("createdAt", 1700000000000L)
|
||||
put("updatedAt", 1700000000000L)
|
||||
put("lastSyncedAt", 0L)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertEndpointPair(
|
||||
db: SupportSQLiteDatabase,
|
||||
id: String,
|
||||
endpoint: String?,
|
||||
endpointType: String?,
|
||||
) {
|
||||
db.query("SELECT endpoint, endpointType FROM conversations WHERE conversationId = '$id'").use { cursor ->
|
||||
assertThat(cursor.moveToFirst()).isTrue()
|
||||
val actualEndpoint = if (cursor.isNull(0)) null else cursor.getString(0)
|
||||
val actualEndpointType = if (cursor.isNull(1)) null else cursor.getString(1)
|
||||
assertThat(actualEndpoint).isEqualTo(endpoint)
|
||||
assertThat(actualEndpointType).isEqualTo(endpointType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import androidx.room.Room
|
|||
import com.garfiec.librechat.core.common.di.KoinQualifiers
|
||||
import com.garfiec.librechat.core.data.datastore.TokenDataStore
|
||||
import com.garfiec.librechat.core.data.db.LibreChatDatabase
|
||||
import com.garfiec.librechat.core.data.db.migration.MIGRATION_3_4
|
||||
import com.garfiec.librechat.core.data.repository.CommonSessionCacheCleaner
|
||||
import com.garfiec.librechat.core.data.repository.RoleRepository
|
||||
import com.garfiec.librechat.core.data.repository.SessionCacheCleaner
|
||||
|
|
@ -28,7 +29,9 @@ actual val dataPlatformModule: Module = module {
|
|||
|
||||
// --- Database ---
|
||||
single {
|
||||
Room.databaseBuilder(androidContext(), LibreChatDatabase::class.java, "librechat.db").build()
|
||||
Room.databaseBuilder(androidContext(), LibreChatDatabase::class.java, "librechat.db")
|
||||
.addMigrations(MIGRATION_3_4)
|
||||
.build()
|
||||
}
|
||||
|
||||
// --- DataStore ---
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import com.garfiec.librechat.core.data.db.entity.PresetEntity
|
|||
ConversationTagEntity::class,
|
||||
DraftEntity::class,
|
||||
],
|
||||
version = 3,
|
||||
version = 4,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
package com.garfiec.librechat.core.data.db.migration
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
|
||||
/**
|
||||
* Pre-#67 builds stored Conversation.endpoint and endpointType as the Kotlin
|
||||
* enum `.name` (e.g. "OPENAI"). #67's writer emits the wire-format SerialName
|
||||
* ("openAI"); the migration rewrites legacy rows in place so the read-side
|
||||
* shim in ConversationMapper can be deleted.
|
||||
*
|
||||
* Hardcoded pairs (not iterated over EModelEndpoint.entries) keep this a
|
||||
* frozen historical artifact — future enum edits do not change which legacy
|
||||
* strings get rewritten.
|
||||
*
|
||||
* Note on lossy AGENTS: pre-#67 ChatPayloadBuilder silently coerced any
|
||||
* unknown endpoint name (OpenRouter, Deepseek, xAI, …) to EModelEndpoint.AGENTS
|
||||
* before persistence. So a legacy row with `endpoint = "AGENTS"` may have
|
||||
* originated from any custom endpoint and the original name is unrecoverable.
|
||||
* Rewriting "AGENTS" -> "agents" preserves the same lossy outcome the
|
||||
* read-side shim already produced. This is by design.
|
||||
*/
|
||||
val MIGRATION_3_4: Migration = object : Migration(3, 4) {
|
||||
override fun migrate(connection: SQLiteConnection) {
|
||||
listOf(
|
||||
"AZURE_OPENAI" to "azureOpenAI",
|
||||
"OPENAI" to "openAI",
|
||||
"GOOGLE" to "google",
|
||||
"ANTHROPIC" to "anthropic",
|
||||
"ASSISTANTS" to "assistants",
|
||||
"AZURE_ASSISTANTS" to "azureAssistants",
|
||||
"AGENTS" to "agents",
|
||||
"CUSTOM" to "custom",
|
||||
"BEDROCK" to "bedrock",
|
||||
).forEach { (legacy, wire) ->
|
||||
connection.execSQL("UPDATE conversations SET endpoint = '$wire' WHERE endpoint = '$legacy'")
|
||||
connection.execSQL("UPDATE conversations SET endpointType = '$wire' WHERE endpointType = '$legacy'")
|
||||
connection.execSQL("UPDATE presets SET endpoint = '$wire' WHERE endpoint = '$legacy'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ package com.garfiec.librechat.core.data.mapper
|
|||
|
||||
import com.garfiec.librechat.core.data.db.entity.ConversationEntity
|
||||
import com.garfiec.librechat.core.model.Conversation
|
||||
import com.garfiec.librechat.core.model.EModelEndpoint
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
|
|
@ -15,8 +14,8 @@ 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 +31,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 = endpoint,
|
||||
endpointType = endpointType,
|
||||
model = model,
|
||||
agentId = agentId,
|
||||
isArchived = isArchived,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,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")
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import com.garfiec.librechat.core.common.di.KoinQualifiers
|
|||
import com.garfiec.librechat.core.data.datastore.IosTokenDataStore
|
||||
import com.garfiec.librechat.core.data.datastore.ServerUrlKeychainFallback
|
||||
import com.garfiec.librechat.core.data.db.LibreChatDatabase
|
||||
import com.garfiec.librechat.core.data.db.migration.MIGRATION_3_4
|
||||
import com.garfiec.librechat.core.data.repository.CommonSessionCacheCleaner
|
||||
import com.garfiec.librechat.core.data.repository.RoleRepository
|
||||
import com.garfiec.librechat.core.data.repository.SessionCacheCleaner
|
||||
|
|
@ -43,6 +44,7 @@ actual val dataPlatformModule: Module = module {
|
|||
Room.databaseBuilder<LibreChatDatabase>(name = "$dbDir/librechat.db")
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.setQueryCoroutineContext(Dispatchers.IO)
|
||||
.addMigrations(MIGRATION_3_4)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.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,
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue