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