From 0f563f8c2bd5ce0d66cf3d3d1a5c95772e881894 Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 15:50:25 +0100 Subject: [PATCH 01/20] =?UTF-8?q?=F0=9F=9A=80=20Feature:=20add=20database?= =?UTF-8?q?=20support=20for=20default=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/llm_config.py | 60 ++++++++++++++++++++++--------- lib/storage.py | 91 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 126 insertions(+), 25 deletions(-) diff --git a/lib/llm_config.py b/lib/llm_config.py index 420d2ad..345c32f 100644 --- a/lib/llm_config.py +++ b/lib/llm_config.py @@ -39,11 +39,13 @@ def __init__(self, storage: Storage): async def get_llm_model(self, name: str | None = None) -> LLMModelConfig: """get llm config by name, or default if name is none + uses fallback chain to ensure blocks always have a model available: uses fallback chain to ensure blocks always have a model available: 1. requested name - 2. model named "default" - 3. first model in db - 4. .env fallback (LLM_ENDPOINT, LLM_API_KEY, LLM_MODEL) + 2. model marked as default (is_default=True) + 3. model named "default" (legacy) + 4. first model in db + 5. .env fallback (LLM_ENDPOINT, LLM_API_KEY, LLM_MODEL) """ if name: config = await self.storage.get_llm_model(name) @@ -53,14 +55,18 @@ async def get_llm_model(self, name: str | None = None) -> LLMModelConfig: f"llm model '{name}' not found", detail={"requested_name": name} ) - # try default model - config = await self.storage.get_llm_model("default") - if config: - return config - - # try first model + # try explicit default model or model named "default" all_models = await self.storage.list_llm_models() if all_models: + # check for is_default=True + for model in all_models: + if model.is_default: + return model + # fallback to name="default" + for model in all_models: + if model.name == "default": + return model + # fallback to first model return all_models[0] # fallback to .env @@ -93,6 +99,12 @@ async def delete_llm_model(self, name: str) -> None: if not success: raise LLMConfigNotFoundError(f"llm model '{name}' not found", detail={"name": name}) + async def set_default_llm_model(self, name: str) -> None: + """set default llm model""" + success = await self.storage.set_default_llm_model(name) + if not success: + raise LLMConfigNotFoundError(f"llm model '{name}' not found", detail={"name": name}) + async def test_llm_connection(self, config: LLMModelConfig) -> ConnectionTestResult: """test llm connection with simple prompt @@ -120,10 +132,12 @@ async def test_llm_connection(self, config: LLMModelConfig) -> ConnectionTestRes async def get_embedding_model(self, name: str | None = None) -> EmbeddingModelConfig: """get embedding config by name, or default if name is none + fallback chain: fallback chain: 1. requested name - 2. model named "default" - 3. first model in db + 2. model marked as default (is_default=True) + 3. model named "default" (legacy) + 4. first model in db """ if name: config = await self.storage.get_embedding_model(name) @@ -133,14 +147,18 @@ async def get_embedding_model(self, name: str | None = None) -> EmbeddingModelCo f"embedding model '{name}' not found", detail={"requested_name": name} ) - # try default model - config = await self.storage.get_embedding_model("default") - if config: - return config - - # try first model + # try explicit default model or model named "default" all_models = await self.storage.list_embedding_models() if all_models: + # check for is_default=True + for model in all_models: + if model.is_default: + return model + # fallback to name="default" + for model in all_models: + if model.name == "default": + return model + # fallback to first model return all_models[0] raise LLMConfigNotFoundError( @@ -163,6 +181,14 @@ async def delete_embedding_model(self, name: str) -> None: f"embedding model '{name}' not found", detail={"name": name} ) + async def set_default_embedding_model(self, name: str) -> None: + """set default embedding model""" + success = await self.storage.set_default_embedding_model(name) + if not success: + raise LLMConfigNotFoundError( + f"embedding model '{name}' not found", detail={"name": name} + ) + async def test_embedding_connection(self, config: EmbeddingModelConfig) -> ConnectionTestResult: """test embedding connection with simple text diff --git a/lib/storage.py b/lib/storage.py index b4b56b1..0fe2c03 100644 --- a/lib/storage.py +++ b/lib/storage.py @@ -192,6 +192,24 @@ async def _migrate_schema(self, db: Connection) -> None: if "metadata" not in job_column_names: await db.execute("ALTER TABLE jobs ADD COLUMN metadata TEXT") + # migrate llm_models table + cursor = await db.execute("PRAGMA table_info(llm_models)") + llm_columns = await cursor.fetchall() + llm_column_names = [col[1] for col in llm_columns] + + if "is_default" not in llm_column_names: + await db.execute("ALTER TABLE llm_models ADD COLUMN is_default BOOLEAN DEFAULT 0") + + # migrate embedding_models table + cursor = await db.execute("PRAGMA table_info(embedding_models)") + embedding_columns = await cursor.fetchall() + embedding_column_names = [col[1] for col in embedding_columns] + + if "is_default" not in embedding_column_names: + await db.execute( + "ALTER TABLE embedding_models ADD COLUMN is_default BOOLEAN DEFAULT 0" + ) + async def _migrate_env_to_db(self, db: Connection) -> None: """migrate .env config to database if no models configured""" # check if any llm models exist @@ -214,8 +232,8 @@ async def _migrate_env_to_db(self, db: Connection) -> None: # create default model from .env await db.execute( """ - INSERT INTO llm_models (name, provider, endpoint, api_key, model_name) - VALUES (?, ?, ?, ?, ?) + INSERT INTO llm_models (name, provider, endpoint, api_key, model_name, is_default) + VALUES (?, ?, ?, ?, ?, ?) """, ( "default", @@ -223,6 +241,7 @@ async def _migrate_env_to_db(self, db: Connection) -> None: settings.LLM_ENDPOINT, settings.LLM_API_KEY if settings.LLM_API_KEY else None, settings.LLM_MODEL, + True, # make env model default if it's the only one ), ) @@ -622,6 +641,7 @@ async def _list(db: Connection) -> list[LLMModelConfig]: endpoint=row["endpoint"], api_key=row["api_key"], model_name=row["model_name"], + is_default=bool(row["is_default"]), ) for row in rows ] @@ -643,6 +663,7 @@ async def _get(db: Connection) -> LLMModelConfig | None: endpoint=row["endpoint"], api_key=row["api_key"], model_name=row["model_name"], + is_default=bool(row["is_default"]), ) return await self._execute_with_connection(_get) @@ -653,13 +674,14 @@ async def save_llm_model(self, config: LLMModelConfig) -> None: async def _save(db: Connection) -> None: await db.execute( """ - INSERT INTO llm_models (name, provider, endpoint, api_key, model_name) - VALUES (?, ?, ?, ?, ?) + INSERT INTO llm_models (name, provider, endpoint, api_key, model_name, is_default) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET provider = excluded.provider, endpoint = excluded.endpoint, api_key = excluded.api_key, - model_name = excluded.model_name + model_name = excluded.model_name, + is_default = excluded.is_default """, ( config.name, @@ -667,6 +689,7 @@ async def _save(db: Connection) -> None: config.endpoint, config.api_key, config.model_name, + config.is_default, ), ) @@ -681,6 +704,29 @@ async def _delete(db: Connection) -> bool: return await self._execute_with_connection(_delete) + async def set_default_llm_model(self, name: str) -> bool: + """set default llm model""" + + async def _set_default(db: Connection) -> bool: + # check if model exists + cursor = await db.execute("SELECT 1 FROM llm_models WHERE name = ?", (name,)) + if not await cursor.fetchone(): + return False + + await db.execute("BEGIN") + try: + # reset all to false + await db.execute("UPDATE llm_models SET is_default = 0") + # set selected to true + await db.execute("UPDATE llm_models SET is_default = 1 WHERE name = ?", (name,)) + await db.execute("COMMIT") + return True + except Exception: + await db.execute("ROLLBACK") + raise + + return await self._execute_with_connection(_set_default) + async def list_embedding_models(self) -> list[EmbeddingModelConfig]: """list all configured embedding models""" @@ -695,6 +741,7 @@ async def _list(db: Connection) -> list[EmbeddingModelConfig]: endpoint=row["endpoint"], api_key=row["api_key"], model_name=row["model_name"], + is_default=bool(row["is_default"]), dimensions=row["dimensions"] or 0, ) for row in rows @@ -717,6 +764,7 @@ async def _get(db: Connection) -> EmbeddingModelConfig | None: endpoint=row["endpoint"], api_key=row["api_key"], model_name=row["model_name"], + is_default=bool(row["is_default"]), dimensions=row["dimensions"] or 0, ) @@ -729,14 +777,15 @@ async def _save(db: Connection) -> None: await db.execute( """ INSERT INTO embedding_models - (name, provider, endpoint, api_key, model_name, dimensions) - VALUES (?, ?, ?, ?, ?, ?) + (name, provider, endpoint, api_key, model_name, dimensions, is_default) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET provider = excluded.provider, endpoint = excluded.endpoint, api_key = excluded.api_key, model_name = excluded.model_name, - dimensions = excluded.dimensions + dimensions = excluded.dimensions, + is_default = excluded.is_default """, ( config.name, @@ -745,6 +794,7 @@ async def _save(db: Connection) -> None: config.api_key, config.model_name, config.dimensions, + config.is_default, ), ) @@ -759,6 +809,31 @@ async def _delete(db: Connection) -> bool: return await self._execute_with_connection(_delete) + async def set_default_embedding_model(self, name: str) -> bool: + """set default embedding model""" + + async def _set_default(db: Connection) -> bool: + # check if model exists + cursor = await db.execute("SELECT 1 FROM embedding_models WHERE name = ?", (name,)) + if not await cursor.fetchone(): + return False + + await db.execute("BEGIN") + try: + # reset all to false + await db.execute("UPDATE embedding_models SET is_default = 0") + # set selected to true + await db.execute( + "UPDATE embedding_models SET is_default = 1 WHERE name = ?", (name,) + ) + await db.execute("COMMIT") + return True + except Exception: + await db.execute("ROLLBACK") + raise + + return await self._execute_with_connection(_set_default) + def _row_to_record(self, row: aiosqlite.Row) -> Record: return Record( id=row["id"], From 623648e65eb6aa18d7b65a1c3755d062f34da170 Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 15:50:49 +0100 Subject: [PATCH 02/20] =?UTF-8?q?=F0=9F=9A=80=20Feature:=20expose=20defaul?= =?UTF-8?q?t=20model=20selection=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 26 ++++++++++++++++++++++++++ lib/entities/llm_config.py | 2 ++ 2 files changed, 28 insertions(+) diff --git a/app.py b/app.py index bbf4c74..5eeaef0 100644 --- a/app.py +++ b/app.py @@ -697,6 +697,19 @@ async def delete_llm_model(name: str) -> dict[str, str]: raise HTTPException(status_code=404, detail=e.message) +@api_router.put("/llm-models/{name}/default") +async def set_default_llm_model(name: str) -> dict[str, str]: + """set default llm model""" + try: + await llm_config_manager.set_default_llm_model(name) + return {"message": "llm model set as default successfully"} + except LLMConfigNotFoundError as e: + raise HTTPException(status_code=404, detail=e.message) + except Exception as e: + logger.exception(f"failed to set default llm model {name}") + raise HTTPException(status_code=400, detail=str(e)) + + @api_router.post("/llm-models/test") async def test_llm_connection(config: LLMModelConfig) -> ConnectionTestResult: """test llm connection""" @@ -752,6 +765,19 @@ async def delete_embedding_model(name: str) -> dict[str, str]: raise HTTPException(status_code=404, detail=e.message) +@api_router.put("/embedding-models/{name}/default") +async def set_default_embedding_model(name: str) -> dict[str, str]: + """set default embedding model""" + try: + await llm_config_manager.set_default_embedding_model(name) + return {"message": "embedding model set as default successfully"} + except LLMConfigNotFoundError as e: + raise HTTPException(status_code=404, detail=e.message) + except Exception as e: + logger.exception(f"failed to set default embedding model {name}") + raise HTTPException(status_code=400, detail=str(e)) + + @api_router.post("/embedding-models/test") async def test_embedding_connection( config: EmbeddingModelConfig, diff --git a/lib/entities/llm_config.py b/lib/entities/llm_config.py index 46dbb32..52e2519 100644 --- a/lib/entities/llm_config.py +++ b/lib/entities/llm_config.py @@ -16,6 +16,7 @@ class LLMModelConfig(BaseModel): endpoint: str = "" api_key: str = "" model_name: str = Field(..., min_length=1) + is_default: bool = False @field_validator("endpoint", "api_key", mode="before") @classmethod @@ -30,6 +31,7 @@ class EmbeddingModelConfig(BaseModel): endpoint: str = "" api_key: str = "" model_name: str = Field(..., min_length=1) + is_default: bool = False dimensions: int = 0 @field_validator("endpoint", "api_key", mode="before") From 01e748a4fae889dffe1c16fa7ccf07f7aedddc9b Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 15:51:17 +0100 Subject: [PATCH 03/20] =?UTF-8?q?=F0=9F=9A=80=20Feature:=20UI=20for=20defa?= =?UTF-8?q?ult=20model=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Settings.tsx | 401 +++++++++++++++----------- frontend/src/services/llmConfigApi.ts | 23 ++ frontend/src/types/index.ts | 2 + 3 files changed, 259 insertions(+), 167 deletions(-) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 3c6dea0..03b179f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -129,6 +129,28 @@ export default function Settings() { } }; + const handleSetDefaultLlm = async (name: string) => { + try { + await llmConfigApi.setDefaultLLMModel(name); + toast.success("Default LLM model updated"); + loadLlmModels(); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + toast.error(`Failed to set default LLM model: ${message}`); + } + }; + + const handleSetDefaultEmbedding = async (name: string) => { + try { + await llmConfigApi.setDefaultEmbeddingModel(name); + toast.success("Default embedding model updated"); + loadEmbeddingModels(); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + toast.error(`Failed to set default embedding model: ${message}`); + } + }; + const handleSaveLlm = async (config: LLMModelConfig) => { try { if (editingLlm) { @@ -208,108 +230,123 @@ export default function Settings() { ) : ( - {llmModels.map((model) => ( - - - - - - {model.name} - - - {model.provider} - - {model.name === "default" && ( + {llmModels.map((model) => { + const isDefault = model.is_default || model.name === "default"; + return ( + !isDefault && handleSetDefaultLlm(model.name)} + sx={{ + p: 3, + border: "1px solid", + borderColor: isDefault ? "success.emphasis" : "border.default", + borderRadius: 2, + bg: isDefault ? "success.subtle" : "canvas.subtle", + cursor: isDefault ? "default" : "pointer", + transition: "all 0.2s", + "&:hover": { + borderColor: isDefault ? "success.emphasis" : "accent.emphasis", + transform: isDefault ? "none" : "translateY(-2px)", + boxShadow: isDefault ? "none" : "shadow.medium", + }, + }} + > + + + + + {model.name} + - default + {model.provider} - )} - - - - model: {model.model_name} - - - {model.endpoint} - - - - - - - { - setEditingLlm(model); - setLlmModalOpen(true); - }} - /> - setDeletingLlm(model.name)} - /> + + + model: {model.model_name} + + + {model.endpoint} + + + + + e.stopPropagation()}> + + { + setEditingLlm(model); + setLlmModalOpen(true); + }} + /> + setDeletingLlm(model.name)} + /> + - - ))} + ); + })} )} @@ -351,92 +388,122 @@ export default function Settings() { ) : ( - {embeddingModels.map((model) => ( - - - - - - {model.name} + {embeddingModels.map((model) => { + const isDefault = model.is_default || model.name === "default"; + return ( + !isDefault && handleSetDefaultEmbedding(model.name)} + sx={{ + p: 3, + border: "1px solid", + borderColor: isDefault ? "success.emphasis" : "border.default", + borderRadius: 2, + bg: isDefault ? "success.subtle" : "canvas.subtle", + cursor: isDefault ? "default" : "pointer", + transition: "all 0.2s", + "&:hover": { + borderColor: isDefault ? "success.emphasis" : "accent.emphasis", + transform: isDefault ? "none" : "translateY(-2px)", + boxShadow: isDefault ? "none" : "shadow.medium", + }, + }} + > + + + + + {model.name} + + + {model.provider} + + {isDefault && ( + + + default + + )} + + + model: {model.model_name} + {model.dimensions && ` (${model.dimensions}d)`} + + + {model.endpoint} - + + e.stopPropagation()}> + + { + setEditingEmbedding(model); + setEmbeddingModalOpen(true); + }} + /> + setDeletingEmbedding(model.name)} + /> - - model: {model.model_name} - {model.dimensions && ` (${model.dimensions}d)`} - - - {model.endpoint} - - - - - - { - setEditingEmbedding(model); - setEmbeddingModalOpen(true); - }} - /> - setDeletingEmbedding(model.name)} - /> - - ))} + ); + })} )} diff --git a/frontend/src/services/llmConfigApi.ts b/frontend/src/services/llmConfigApi.ts index 5b572d7..8b1a9dd 100644 --- a/frontend/src/services/llmConfigApi.ts +++ b/frontend/src/services/llmConfigApi.ts @@ -50,6 +50,16 @@ class LLMConfigApi { } } + async setDefaultLLMModel(name: string): Promise { + const response = await fetch(`${API_BASE}/llm-models/${encodeURIComponent(name)}/default`, { + method: "PUT", + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || `http ${response.status}`); + } + } + async testLLMConnection(config: LLMModelConfig): Promise { const response = await fetch(`${API_BASE}/llm-models/test`, { method: "POST", @@ -107,6 +117,19 @@ class LLMConfigApi { } } + async setDefaultEmbeddingModel(name: string): Promise { + const response = await fetch( + `${API_BASE}/embedding-models/${encodeURIComponent(name)}/default`, + { + method: "PUT", + } + ); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || `http ${response.status}`); + } + } + async testEmbeddingConnection(config: EmbeddingModelConfig): Promise { const response = await fetch(`${API_BASE}/embedding-models/test`, { method: "POST", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c736226..5e2a1e1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -95,6 +95,7 @@ export interface LLMModelConfig { endpoint: string; api_key: string | null; model_name: string; + is_default?: boolean; } export interface EmbeddingModelConfig { @@ -104,6 +105,7 @@ export interface EmbeddingModelConfig { api_key: string | null; model_name: string; dimensions: number | null; + is_default?: boolean; } export interface ConnectionTestResult { From 2fe7bab6f43b274987b0db935198c203889f310f Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 16:31:21 +0100 Subject: [PATCH 04/20] test: integration tests for default model selection --- ...est_default_model_selection_integration.py | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/integration/test_default_model_selection_integration.py diff --git a/tests/integration/test_default_model_selection_integration.py b/tests/integration/test_default_model_selection_integration.py new file mode 100644 index 0000000..aeb6c3d --- /dev/null +++ b/tests/integration/test_default_model_selection_integration.py @@ -0,0 +1,148 @@ +import pytest +import pytest_asyncio +from lib.entities import LLMModelConfig, EmbeddingModelConfig, LLMProvider +from lib.llm_config import LLMConfigManager +from lib.storage import Storage + +@pytest_asyncio.fixture +async def storage(): + """create in-memory storage for testing""" + storage = Storage(":memory:") + await storage.init_db() + + # Clear any models created by auto-migration from env + await storage._execute_with_connection(lambda db: db.execute("DELETE FROM llm_models")) + await storage._execute_with_connection(lambda db: db.execute("DELETE FROM embedding_models")) + + yield storage + await storage.close() + +@pytest_asyncio.fixture +async def llm_config_manager(storage): + """create llm config manager with test storage""" + return LLMConfigManager(storage) + +@pytest.mark.asyncio +async def test_llm_default_selection_flow(llm_config_manager): + """ + Test the flow of setting and retrieving default LLM models. + + Verifies: + 1. Fallback to first model when no default is set. + 2. Explicit default selection. + 3. Ensuring only one model is default at a time. + 4. Fallback to 'default' named model (legacy support). + """ + + # 1. Create a few models + model1 = LLMModelConfig( + name="gpt-4", + provider=LLMProvider.OPENAI, + model_name="gpt-4", + is_default=False + ) + model2 = LLMModelConfig( + name="claude-3", + provider=LLMProvider.ANTHROPIC, + model_name="claude-3-opus", + is_default=False + ) + model3 = LLMModelConfig( + name="gemini-pro", + provider=LLMProvider.GEMINI, + model_name="gemini-pro", + is_default=False + ) + + await llm_config_manager.save_llm_model(model1) + await llm_config_manager.save_llm_model(model2) + await llm_config_manager.save_llm_model(model3) + + # Validation 1: No explicit default, should return first one (ordering might depend on DB, usually insertion order) + # We just ensure it returns *one* of them. + default_model = await llm_config_manager.get_llm_model(None) + assert default_model.name in ["gpt-4", "claude-3", "gemini-pro"] + + # Validation 2: Set model2 as default + await llm_config_manager.set_default_llm_model("claude-3") + + # Check if retrieval returns model2 + default_model = await llm_config_manager.get_llm_model(None) + assert default_model.name == "claude-3" + assert default_model.is_default is True + + # Verify others are NOT default + m1 = await llm_config_manager.get_llm_model("gpt-4") + m3 = await llm_config_manager.get_llm_model("gemini-pro") + assert m1.is_default is False + assert m3.is_default is False + + # Validation 3: Switch default to model3 + await llm_config_manager.set_default_llm_model("gemini-pro") + + default_model = await llm_config_manager.get_llm_model(None) + assert default_model.name == "gemini-pro" + assert default_model.is_default is True + + # Verify model2 is no longer default + m2 = await llm_config_manager.get_llm_model("claude-3") + assert m2.is_default is False + +@pytest.mark.asyncio +async def test_embedding_default_selection_flow(llm_config_manager): + """ + Test the flow of setting and retrieving default Embedding models. + + Verifies: + 1. Fallback to first model when no default is set. + 2. Explicit default selection. + 3. Ensuring only one model is default at a time. + 4. Switching default model updates correctly. + """ + embed1 = EmbeddingModelConfig( + name="openai-embed", + provider=LLMProvider.OPENAI, + model_name="text-embedding-3-small", + is_default=False + ) + embed2 = EmbeddingModelConfig( + name="local-embed", + provider=LLMProvider.OLLAMA, + model_name="nomic-embed-text", + is_default=False + ) + + await llm_config_manager.save_embedding_model(embed1) + await llm_config_manager.save_embedding_model(embed2) + + # 1. No default set, returns one of them + default_model = await llm_config_manager.get_embedding_model(None) + assert default_model.name in ["openai-embed", "local-embed"] + + # 2. Set default + await llm_config_manager.set_default_embedding_model("local-embed") + + default_model = await llm_config_manager.get_embedding_model(None) + assert default_model.name == "local-embed" + assert default_model.is_default is True + + # Check other is not default + e1 = await llm_config_manager.get_embedding_model("openai-embed") + assert e1.is_default is False + + # 3. Switch default + await llm_config_manager.set_default_embedding_model("openai-embed") + + default_model = await llm_config_manager.get_embedding_model(None) + assert default_model.name == "openai-embed" + assert default_model.is_default is True + + e2 = await llm_config_manager.get_embedding_model("local-embed") + assert e2.is_default is False + +@pytest.mark.asyncio +async def test_set_nonexistent_default_raises_error(llm_config_manager): + """Test setting a non-existent model as default raises error""" + with pytest.raises(Exception): # storage likely raises a ValueError or similar, manager re-raises or wraps + # The manager method set_default_llm_model catches storage false return and raises LLMConfigNotFoundError + await llm_config_manager.set_default_llm_model("non_existent_model") From 111aef6fbdd212432fef3e9108f34f13862c661a Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 16:37:34 +0100 Subject: [PATCH 05/20] =?UTF-8?q?=F0=9F=93=9A=20Docs:=20updated=20files=20?= =?UTF-8?q?inside=20llm/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm/state-backend.md | 2 ++ llm/state-frontend.md | 2 +- llm/state-project.md | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/llm/state-backend.md b/llm/state-backend.md index a31744b..9fc00ad 100644 --- a/llm/state-backend.md +++ b/llm/state-backend.md @@ -84,6 +84,7 @@ config.py # env Settings - `POST /api/llm-models` - create config - `PUT /api/llm-models/{name}` - update config - `DELETE /api/llm-models/{name}` - delete config +- `PUT /api/llm-models/{name}/default` - set default model - `POST /api/llm-models/test` - test connection ### embedding config @@ -92,6 +93,7 @@ config.py # env Settings - `POST /api/embedding-models` - create config - `PUT /api/embedding-models/{name}` - update config - `DELETE /api/embedding-models/{name}` - delete config +- `PUT /api/embedding-models/{name}/default` - set default model - `POST /api/embedding-models/test` - test connection ## database schema diff --git a/llm/state-frontend.md b/llm/state-frontend.md index 9860c42..b88a234 100644 --- a/llm/state-frontend.md +++ b/llm/state-frontend.md @@ -154,7 +154,7 @@ shadcn radix-ui dialog, replaces browser confirm() **endpoints:** - GET /api/blocks, /api/templates, /api/pipelines, /api/jobs/active, /api/jobs/{id}, /api/records - POST /api/pipelines, /api/pipelines/from_template/{id}, /api/generate, /api/seeds/validate -- PUT /api/records/{id}, /api/llm-models/{name}, /api/embedding-models/{name} +- PUT /api/records/{id}, /api/llm-models/{name}, /api/embedding-models/{name}, /api/llm-models/{name}/default, /api/embedding-models/{name}/default - DELETE /api/pipelines/{id}, /api/jobs/{id}, /api/records - GET /api/export/download, /api/llm-models, /api/embedding-models diff --git a/llm/state-project.md b/llm/state-project.md index 0e693c5..222b34c 100644 --- a/llm/state-project.md +++ b/llm/state-project.md @@ -371,7 +371,7 @@ production-ready full-stack data generation platform - structured errors with context - sqlite with migrations - type-safe BlockExecutionContext -- LLM/embedding config management (multi-provider) +- LLM/embedding config management (multi-provider) + default model selection - 4 pages: Pipelines, Generator, Review, Settings - primer + dark mode - accumulated state visualization From e6de5d2bccdf938cf626fa0345aa9328b0b4f3ac Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 17:12:52 +0100 Subject: [PATCH 06/20] =?UTF-8?q?=F0=9F=A7=A9=20Fix:=20default=20model=20s?= =?UTF-8?q?election=20and=20implement=20auto-default=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed frontend display bug where models named "default" incorrectly overrode explicit default settings. Updated lib/storage.py to strictly enforce a single default model constraint. Implemented auto-default logic: the first model created is automatically set as default. Implemented default reassignment: deleting the current default model automatically promotes another model to default. Added integration tests covering auto-default and reassignment scenarios. --- frontend/src/pages/Settings.tsx | 2 +- lib/storage.py | 165 +++++++++++++------ tests/integration/test_auto_default_logic.py | 107 ++++++++++++ 3 files changed, 226 insertions(+), 48 deletions(-) create mode 100644 tests/integration/test_auto_default_logic.py diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 03b179f..6d04e9d 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -231,7 +231,7 @@ export default function Settings() { ) : ( {llmModels.map((model) => { - const isDefault = model.is_default || model.name === "default"; + const isDefault = model.is_default; return ( None: """create or update llm model config (upsert)""" async def _save(db: Connection) -> None: - await db.execute( - """ - INSERT INTO llm_models (name, provider, endpoint, api_key, model_name, is_default) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - provider = excluded.provider, - endpoint = excluded.endpoint, - api_key = excluded.api_key, - model_name = excluded.model_name, - is_default = excluded.is_default - """, - ( - config.name, - config.provider.value, - config.endpoint, - config.api_key, - config.model_name, - config.is_default, - ), - ) + await db.execute("BEGIN") + try: + # check if this is the first model inside transaction + cursor = await db.execute("SELECT COUNT(*) FROM llm_models") + row = await cursor.fetchone() + count = row[0] if row else 0 + + final_is_default = config.is_default or count == 0 + + if final_is_default: + await db.execute("UPDATE llm_models SET is_default = 0") + + await db.execute( + """ + INSERT INTO llm_models (name, provider, endpoint, api_key, model_name, is_default) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + provider = excluded.provider, + endpoint = excluded.endpoint, + api_key = excluded.api_key, + model_name = excluded.model_name, + is_default = excluded.is_default + """, + ( + config.name, + config.provider.value, + config.endpoint, + config.api_key, + config.model_name, + final_is_default, + ), + ) + await db.execute("COMMIT") + except Exception: + await db.execute("ROLLBACK") + raise await self._execute_with_connection(_save) @@ -699,8 +715,28 @@ async def delete_llm_model(self, name: str) -> bool: """delete llm model config""" async def _delete(db: Connection) -> bool: - cursor = await db.execute("DELETE FROM llm_models WHERE name = ?", (name,)) - return cursor.rowcount > 0 + await db.execute("BEGIN") + try: + cursor = await db.execute("DELETE FROM llm_models WHERE name = ?", (name,)) + deleted = cursor.rowcount > 0 + + if deleted: + # if we deleted the default model (or the last default), pick a new one + # this query updates a model to default ONLY IF no default currently exists + await db.execute( + """ + UPDATE llm_models + SET is_default = 1 + WHERE name = (SELECT name FROM llm_models ORDER BY name LIMIT 1) + AND (SELECT COUNT(*) FROM llm_models WHERE is_default = 1) = 0 + """ + ) + + await db.execute("COMMIT") + return deleted + except Exception: + await db.execute("ROLLBACK") + raise return await self._execute_with_connection(_delete) @@ -774,29 +810,45 @@ async def save_embedding_model(self, config: EmbeddingModelConfig) -> None: """create or update embedding model config (upsert)""" async def _save(db: Connection) -> None: - await db.execute( - """ - INSERT INTO embedding_models - (name, provider, endpoint, api_key, model_name, dimensions, is_default) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - provider = excluded.provider, - endpoint = excluded.endpoint, - api_key = excluded.api_key, - model_name = excluded.model_name, - dimensions = excluded.dimensions, - is_default = excluded.is_default - """, - ( - config.name, - config.provider.value, - config.endpoint, - config.api_key, - config.model_name, - config.dimensions, - config.is_default, - ), - ) + await db.execute("BEGIN") + try: + # check if this is the first model inside transaction + cursor = await db.execute("SELECT COUNT(*) FROM embedding_models") + row = await cursor.fetchone() + count = row[0] if row else 0 + + final_is_default = config.is_default or count == 0 + + if final_is_default: + await db.execute("UPDATE embedding_models SET is_default = 0") + + await db.execute( + """ + INSERT INTO embedding_models + (name, provider, endpoint, api_key, model_name, dimensions, is_default) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + provider = excluded.provider, + endpoint = excluded.endpoint, + api_key = excluded.api_key, + model_name = excluded.model_name, + dimensions = excluded.dimensions, + is_default = excluded.is_default + """, + ( + config.name, + config.provider.value, + config.endpoint, + config.api_key, + config.model_name, + config.dimensions, + final_is_default, + ), + ) + await db.execute("COMMIT") + except Exception: + await db.execute("ROLLBACK") + raise await self._execute_with_connection(_save) @@ -804,8 +856,27 @@ async def delete_embedding_model(self, name: str) -> bool: """delete embedding model config""" async def _delete(db: Connection) -> bool: - cursor = await db.execute("DELETE FROM embedding_models WHERE name = ?", (name,)) - return cursor.rowcount > 0 + await db.execute("BEGIN") + try: + cursor = await db.execute("DELETE FROM embedding_models WHERE name = ?", (name,)) + deleted = cursor.rowcount > 0 + + if deleted: + # if we deleted the default model (or the last default), pick a new one + await db.execute( + """ + UPDATE embedding_models + SET is_default = 1 + WHERE name = (SELECT name FROM embedding_models ORDER BY name LIMIT 1) + AND (SELECT COUNT(*) FROM embedding_models WHERE is_default = 1) = 0 + """ + ) + + await db.execute("COMMIT") + return deleted + except Exception: + await db.execute("ROLLBACK") + raise return await self._execute_with_connection(_delete) diff --git a/tests/integration/test_auto_default_logic.py b/tests/integration/test_auto_default_logic.py new file mode 100644 index 0000000..e74d6d8 --- /dev/null +++ b/tests/integration/test_auto_default_logic.py @@ -0,0 +1,107 @@ +import pytest +from lib.entities import LLMModelConfig, LLMProvider, EmbeddingModelConfig +from lib.storage import Storage + +@pytest.mark.asyncio +async def test_llm_auto_default_logic(storage: Storage): + # Clear tables to remove auto-migrated models + await storage._execute_with_connection( + lambda db: db.execute("DELETE FROM llm_models") + ) + + # 1. Test auto-default on first creation + model1 = LLMModelConfig( + name="model1", + provider=LLMProvider.OPENAI, + model_name="gpt-4", + is_default=False # Explicitly False + ) + await storage.save_llm_model(model1) + + saved_model1 = await storage.get_llm_model("model1") + assert saved_model1 is not None + assert saved_model1.is_default is True, "First model should be auto-set to default even if is_default=False" + + # 2. Test adds second model (should NOT be default) + model2 = LLMModelConfig( + name="model2", + provider=LLMProvider.ANTHROPIC, + model_name="claude-3", + is_default=False + ) + await storage.save_llm_model(model2) + + saved_model2 = await storage.get_llm_model("model2") + assert saved_model2.is_default is False + + # Verify model1 is still default + saved_model1 = await storage.get_llm_model("model1") + assert saved_model1.is_default is True + + # 3. Test auto-default on delete to one + # Delete model1 (default), model2 should become default + await storage.delete_llm_model("model1") + + saved_model2 = await storage.get_llm_model("model2") + assert saved_model2.is_default is True, "Remaining single model should become default" + + # 4. Test default reassignment when multiple models exist + # Setup: Create model3, ensure model2 is default. + model3 = LLMModelConfig( + name="model3", + provider=LLMProvider.OLLAMA, + model_name="llama2", + is_default=False + ) + await storage.save_llm_model(model3) + + # model2 is currently default. model3 is not. + m2 = await storage.get_llm_model("model2") + m3 = await storage.get_llm_model("model3") + assert m2.is_default is True + assert m3.is_default is False + + # Delete the current default (model2) + # We expect model3 to become default (since it's the only other one, or alphabetical) + await storage.delete_llm_model("model2") + + saved_model3 = await storage.get_llm_model("model3") + assert saved_model3.is_default is True, "Deleting default model should reassign default to available model" + +@pytest.mark.asyncio +async def test_embedding_auto_default_logic(storage: Storage): + # Clear tables to remove auto-migrated models + await storage._execute_with_connection( + lambda db: db.execute("DELETE FROM embedding_models") + ) + + # 1. Test auto-default on first creation + model1 = EmbeddingModelConfig( + name="emb1", + provider=LLMProvider.OPENAI, + model_name="text-embedding-3-small", + is_default=False + ) + await storage.save_embedding_model(model1) + + saved_model1 = await storage.get_embedding_model("emb1") + assert saved_model1 is not None + assert saved_model1.is_default is True, "First embedding model should be auto-set to default" + + # 2. Add second model + model2 = EmbeddingModelConfig( + name="emb2", + provider=LLMProvider.GEMINI, + model_name="embedding-001", + is_default=False + ) + await storage.save_embedding_model(model2) + + saved_model2 = await storage.get_embedding_model("emb2") + assert saved_model2.is_default is False + + # 3. Test delete to one + await storage.delete_embedding_model("emb1") + + saved_model2 = await storage.get_embedding_model("emb2") + assert saved_model2.is_default is True, "Remaining single embedding model should become default" From d24ea7f683f19f42ff1b606378d45951dc11442b Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 17:27:56 +0100 Subject: [PATCH 07/20] =?UTF-8?q?=F0=9F=93=90=20Refactor:=20ran=20make=20f?= =?UTF-8?q?ormat=20and=20pre-merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Settings.tsx | 18 +++-- lib/storage.py | 23 +++--- tests/integration/test_auto_default_logic.py | 62 ++++++++-------- ...est_default_model_selection_integration.py | 70 ++++++++++--------- 4 files changed, 89 insertions(+), 84 deletions(-) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 6d04e9d..1d4ef51 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -251,7 +251,9 @@ export default function Settings() { }, }} > - + @@ -300,7 +302,10 @@ export default function Settings() { - e.stopPropagation()}> + e.stopPropagation()} + > - { - setEditingLlm(model); - setLlmModalOpen(true); - }} - /> - setDeletingLlm(model.name)} - /> + + + model: {model.model_name} + + + {model.endpoint} + + + + + e.stopPropagation()}> + + { + setEditingLlm(model); + setLlmModalOpen(true); + }} + /> + setDeletingLlm(model.name)} + /> + - - ))} + ); + })} )} @@ -351,92 +388,122 @@ export default function Settings() { ) : ( - {embeddingModels.map((model) => ( - - - - - - {model.name} + {embeddingModels.map((model) => { + const isDefault = model.is_default || model.name === "default"; + return ( + !isDefault && handleSetDefaultEmbedding(model.name)} + sx={{ + p: 3, + border: "1px solid", + borderColor: isDefault ? "success.emphasis" : "border.default", + borderRadius: 2, + bg: isDefault ? "success.subtle" : "canvas.subtle", + cursor: isDefault ? "default" : "pointer", + transition: "all 0.2s", + "&:hover": { + borderColor: isDefault ? "success.emphasis" : "accent.emphasis", + transform: isDefault ? "none" : "translateY(-2px)", + boxShadow: isDefault ? "none" : "shadow.medium", + }, + }} + > + + + + + {model.name} + + + {model.provider} + + {isDefault && ( + + + default + + )} + + + model: {model.model_name} + {model.dimensions && ` (${model.dimensions}d)`} + + + {model.endpoint} - + + e.stopPropagation()}> + + { + setEditingEmbedding(model); + setEmbeddingModalOpen(true); + }} + /> + setDeletingEmbedding(model.name)} + /> - - model: {model.model_name} - {model.dimensions && ` (${model.dimensions}d)`} - - - {model.endpoint} - - - - - - { - setEditingEmbedding(model); - setEmbeddingModalOpen(true); - }} - /> - setDeletingEmbedding(model.name)} - /> - - ))} + ); + })} )} diff --git a/frontend/src/services/llmConfigApi.ts b/frontend/src/services/llmConfigApi.ts index 5b572d7..8b1a9dd 100644 --- a/frontend/src/services/llmConfigApi.ts +++ b/frontend/src/services/llmConfigApi.ts @@ -50,6 +50,16 @@ class LLMConfigApi { } } + async setDefaultLLMModel(name: string): Promise { + const response = await fetch(`${API_BASE}/llm-models/${encodeURIComponent(name)}/default`, { + method: "PUT", + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || `http ${response.status}`); + } + } + async testLLMConnection(config: LLMModelConfig): Promise { const response = await fetch(`${API_BASE}/llm-models/test`, { method: "POST", @@ -107,6 +117,19 @@ class LLMConfigApi { } } + async setDefaultEmbeddingModel(name: string): Promise { + const response = await fetch( + `${API_BASE}/embedding-models/${encodeURIComponent(name)}/default`, + { + method: "PUT", + } + ); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || `http ${response.status}`); + } + } + async testEmbeddingConnection(config: EmbeddingModelConfig): Promise { const response = await fetch(`${API_BASE}/embedding-models/test`, { method: "POST", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c736226..5e2a1e1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -95,6 +95,7 @@ export interface LLMModelConfig { endpoint: string; api_key: string | null; model_name: string; + is_default?: boolean; } export interface EmbeddingModelConfig { @@ -104,6 +105,7 @@ export interface EmbeddingModelConfig { api_key: string | null; model_name: string; dimensions: number | null; + is_default?: boolean; } export interface ConnectionTestResult { From a2ac623f5915b0d51989e7b077d883b4fa0638bf Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 16:31:21 +0100 Subject: [PATCH 11/20] test: integration tests for default model selection --- ...est_default_model_selection_integration.py | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/integration/test_default_model_selection_integration.py diff --git a/tests/integration/test_default_model_selection_integration.py b/tests/integration/test_default_model_selection_integration.py new file mode 100644 index 0000000..aeb6c3d --- /dev/null +++ b/tests/integration/test_default_model_selection_integration.py @@ -0,0 +1,148 @@ +import pytest +import pytest_asyncio +from lib.entities import LLMModelConfig, EmbeddingModelConfig, LLMProvider +from lib.llm_config import LLMConfigManager +from lib.storage import Storage + +@pytest_asyncio.fixture +async def storage(): + """create in-memory storage for testing""" + storage = Storage(":memory:") + await storage.init_db() + + # Clear any models created by auto-migration from env + await storage._execute_with_connection(lambda db: db.execute("DELETE FROM llm_models")) + await storage._execute_with_connection(lambda db: db.execute("DELETE FROM embedding_models")) + + yield storage + await storage.close() + +@pytest_asyncio.fixture +async def llm_config_manager(storage): + """create llm config manager with test storage""" + return LLMConfigManager(storage) + +@pytest.mark.asyncio +async def test_llm_default_selection_flow(llm_config_manager): + """ + Test the flow of setting and retrieving default LLM models. + + Verifies: + 1. Fallback to first model when no default is set. + 2. Explicit default selection. + 3. Ensuring only one model is default at a time. + 4. Fallback to 'default' named model (legacy support). + """ + + # 1. Create a few models + model1 = LLMModelConfig( + name="gpt-4", + provider=LLMProvider.OPENAI, + model_name="gpt-4", + is_default=False + ) + model2 = LLMModelConfig( + name="claude-3", + provider=LLMProvider.ANTHROPIC, + model_name="claude-3-opus", + is_default=False + ) + model3 = LLMModelConfig( + name="gemini-pro", + provider=LLMProvider.GEMINI, + model_name="gemini-pro", + is_default=False + ) + + await llm_config_manager.save_llm_model(model1) + await llm_config_manager.save_llm_model(model2) + await llm_config_manager.save_llm_model(model3) + + # Validation 1: No explicit default, should return first one (ordering might depend on DB, usually insertion order) + # We just ensure it returns *one* of them. + default_model = await llm_config_manager.get_llm_model(None) + assert default_model.name in ["gpt-4", "claude-3", "gemini-pro"] + + # Validation 2: Set model2 as default + await llm_config_manager.set_default_llm_model("claude-3") + + # Check if retrieval returns model2 + default_model = await llm_config_manager.get_llm_model(None) + assert default_model.name == "claude-3" + assert default_model.is_default is True + + # Verify others are NOT default + m1 = await llm_config_manager.get_llm_model("gpt-4") + m3 = await llm_config_manager.get_llm_model("gemini-pro") + assert m1.is_default is False + assert m3.is_default is False + + # Validation 3: Switch default to model3 + await llm_config_manager.set_default_llm_model("gemini-pro") + + default_model = await llm_config_manager.get_llm_model(None) + assert default_model.name == "gemini-pro" + assert default_model.is_default is True + + # Verify model2 is no longer default + m2 = await llm_config_manager.get_llm_model("claude-3") + assert m2.is_default is False + +@pytest.mark.asyncio +async def test_embedding_default_selection_flow(llm_config_manager): + """ + Test the flow of setting and retrieving default Embedding models. + + Verifies: + 1. Fallback to first model when no default is set. + 2. Explicit default selection. + 3. Ensuring only one model is default at a time. + 4. Switching default model updates correctly. + """ + embed1 = EmbeddingModelConfig( + name="openai-embed", + provider=LLMProvider.OPENAI, + model_name="text-embedding-3-small", + is_default=False + ) + embed2 = EmbeddingModelConfig( + name="local-embed", + provider=LLMProvider.OLLAMA, + model_name="nomic-embed-text", + is_default=False + ) + + await llm_config_manager.save_embedding_model(embed1) + await llm_config_manager.save_embedding_model(embed2) + + # 1. No default set, returns one of them + default_model = await llm_config_manager.get_embedding_model(None) + assert default_model.name in ["openai-embed", "local-embed"] + + # 2. Set default + await llm_config_manager.set_default_embedding_model("local-embed") + + default_model = await llm_config_manager.get_embedding_model(None) + assert default_model.name == "local-embed" + assert default_model.is_default is True + + # Check other is not default + e1 = await llm_config_manager.get_embedding_model("openai-embed") + assert e1.is_default is False + + # 3. Switch default + await llm_config_manager.set_default_embedding_model("openai-embed") + + default_model = await llm_config_manager.get_embedding_model(None) + assert default_model.name == "openai-embed" + assert default_model.is_default is True + + e2 = await llm_config_manager.get_embedding_model("local-embed") + assert e2.is_default is False + +@pytest.mark.asyncio +async def test_set_nonexistent_default_raises_error(llm_config_manager): + """Test setting a non-existent model as default raises error""" + with pytest.raises(Exception): # storage likely raises a ValueError or similar, manager re-raises or wraps + # The manager method set_default_llm_model catches storage false return and raises LLMConfigNotFoundError + await llm_config_manager.set_default_llm_model("non_existent_model") From b4505ab9b8ee8ff328589ece191cdbdb64e7f1e6 Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 16:37:34 +0100 Subject: [PATCH 12/20] =?UTF-8?q?=F0=9F=93=9A=20Docs:=20updated=20files=20?= =?UTF-8?q?inside=20llm/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm/state-backend.md | 2 ++ llm/state-frontend.md | 2 +- llm/state-project.md | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/llm/state-backend.md b/llm/state-backend.md index d6660f3..817877c 100644 --- a/llm/state-backend.md +++ b/llm/state-backend.md @@ -85,6 +85,7 @@ config.py # env Settings - `POST /api/llm-models` - create config - `PUT /api/llm-models/{name}` - update config - `DELETE /api/llm-models/{name}` - delete config +- `PUT /api/llm-models/{name}/default` - set default model - `POST /api/llm-models/test` - test connection ### embedding config @@ -93,6 +94,7 @@ config.py # env Settings - `POST /api/embedding-models` - create config - `PUT /api/embedding-models/{name}` - update config - `DELETE /api/embedding-models/{name}` - delete config +- `PUT /api/embedding-models/{name}/default` - set default model - `POST /api/embedding-models/test` - test connection ## database schema diff --git a/llm/state-frontend.md b/llm/state-frontend.md index e4248f5..02201de 100644 --- a/llm/state-frontend.md +++ b/llm/state-frontend.md @@ -157,7 +157,7 @@ shadcn radix-ui dialog, replaces browser confirm() **endpoints:** - GET /api/blocks, /api/templates, /api/pipelines, /api/jobs/active, /api/jobs/{id}, /api/records - POST /api/pipelines, /api/pipelines/from_template/{id}, /api/generate, /api/seeds/validate -- PUT /api/records/{id}, /api/llm-models/{name}, /api/embedding-models/{name} +- PUT /api/records/{id}, /api/llm-models/{name}, /api/embedding-models/{name}, /api/llm-models/{name}/default, /api/embedding-models/{name}/default - DELETE /api/pipelines/{id}, /api/jobs/{id}, /api/records - GET /api/export/download, /api/llm-models, /api/embedding-models diff --git a/llm/state-project.md b/llm/state-project.md index 9c571a3..5a8f51f 100644 --- a/llm/state-project.md +++ b/llm/state-project.md @@ -388,7 +388,7 @@ production-ready full-stack data generation platform - structured errors with context - sqlite with migrations - type-safe BlockExecutionContext -- LLM/embedding config management (multi-provider) +- LLM/embedding config management (multi-provider) + default model selection - 4 pages: Pipelines, Generator, Review, Settings - primer + dark mode - accumulated state visualization From 0c1ce1caabe3b507596fc880df865c937b87adfe Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 17:12:52 +0100 Subject: [PATCH 13/20] =?UTF-8?q?=F0=9F=A7=A9=20Fix:=20default=20model=20s?= =?UTF-8?q?election=20and=20implement=20auto-default=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed frontend display bug where models named "default" incorrectly overrode explicit default settings. Updated lib/storage.py to strictly enforce a single default model constraint. Implemented auto-default logic: the first model created is automatically set as default. Implemented default reassignment: deleting the current default model automatically promotes another model to default. Added integration tests covering auto-default and reassignment scenarios. --- frontend/src/pages/Settings.tsx | 2 +- lib/storage.py | 165 +++++++++++++------ tests/integration/test_auto_default_logic.py | 107 ++++++++++++ 3 files changed, 226 insertions(+), 48 deletions(-) create mode 100644 tests/integration/test_auto_default_logic.py diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 03b179f..6d04e9d 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -231,7 +231,7 @@ export default function Settings() { ) : ( {llmModels.map((model) => { - const isDefault = model.is_default || model.name === "default"; + const isDefault = model.is_default; return ( None: """create or update llm model config (upsert)""" async def _save(db: Connection) -> None: - await db.execute( - """ - INSERT INTO llm_models (name, provider, endpoint, api_key, model_name, is_default) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - provider = excluded.provider, - endpoint = excluded.endpoint, - api_key = excluded.api_key, - model_name = excluded.model_name, - is_default = excluded.is_default - """, - ( - config.name, - config.provider.value, - config.endpoint, - config.api_key, - config.model_name, - config.is_default, - ), - ) + await db.execute("BEGIN") + try: + # check if this is the first model inside transaction + cursor = await db.execute("SELECT COUNT(*) FROM llm_models") + row = await cursor.fetchone() + count = row[0] if row else 0 + + final_is_default = config.is_default or count == 0 + + if final_is_default: + await db.execute("UPDATE llm_models SET is_default = 0") + + await db.execute( + """ + INSERT INTO llm_models (name, provider, endpoint, api_key, model_name, is_default) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + provider = excluded.provider, + endpoint = excluded.endpoint, + api_key = excluded.api_key, + model_name = excluded.model_name, + is_default = excluded.is_default + """, + ( + config.name, + config.provider.value, + config.endpoint, + config.api_key, + config.model_name, + final_is_default, + ), + ) + await db.execute("COMMIT") + except Exception: + await db.execute("ROLLBACK") + raise await self._execute_with_connection(_save) @@ -699,8 +715,28 @@ async def delete_llm_model(self, name: str) -> bool: """delete llm model config""" async def _delete(db: Connection) -> bool: - cursor = await db.execute("DELETE FROM llm_models WHERE name = ?", (name,)) - return cursor.rowcount > 0 + await db.execute("BEGIN") + try: + cursor = await db.execute("DELETE FROM llm_models WHERE name = ?", (name,)) + deleted = cursor.rowcount > 0 + + if deleted: + # if we deleted the default model (or the last default), pick a new one + # this query updates a model to default ONLY IF no default currently exists + await db.execute( + """ + UPDATE llm_models + SET is_default = 1 + WHERE name = (SELECT name FROM llm_models ORDER BY name LIMIT 1) + AND (SELECT COUNT(*) FROM llm_models WHERE is_default = 1) = 0 + """ + ) + + await db.execute("COMMIT") + return deleted + except Exception: + await db.execute("ROLLBACK") + raise return await self._execute_with_connection(_delete) @@ -774,29 +810,45 @@ async def save_embedding_model(self, config: EmbeddingModelConfig) -> None: """create or update embedding model config (upsert)""" async def _save(db: Connection) -> None: - await db.execute( - """ - INSERT INTO embedding_models - (name, provider, endpoint, api_key, model_name, dimensions, is_default) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - provider = excluded.provider, - endpoint = excluded.endpoint, - api_key = excluded.api_key, - model_name = excluded.model_name, - dimensions = excluded.dimensions, - is_default = excluded.is_default - """, - ( - config.name, - config.provider.value, - config.endpoint, - config.api_key, - config.model_name, - config.dimensions, - config.is_default, - ), - ) + await db.execute("BEGIN") + try: + # check if this is the first model inside transaction + cursor = await db.execute("SELECT COUNT(*) FROM embedding_models") + row = await cursor.fetchone() + count = row[0] if row else 0 + + final_is_default = config.is_default or count == 0 + + if final_is_default: + await db.execute("UPDATE embedding_models SET is_default = 0") + + await db.execute( + """ + INSERT INTO embedding_models + (name, provider, endpoint, api_key, model_name, dimensions, is_default) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + provider = excluded.provider, + endpoint = excluded.endpoint, + api_key = excluded.api_key, + model_name = excluded.model_name, + dimensions = excluded.dimensions, + is_default = excluded.is_default + """, + ( + config.name, + config.provider.value, + config.endpoint, + config.api_key, + config.model_name, + config.dimensions, + final_is_default, + ), + ) + await db.execute("COMMIT") + except Exception: + await db.execute("ROLLBACK") + raise await self._execute_with_connection(_save) @@ -804,8 +856,27 @@ async def delete_embedding_model(self, name: str) -> bool: """delete embedding model config""" async def _delete(db: Connection) -> bool: - cursor = await db.execute("DELETE FROM embedding_models WHERE name = ?", (name,)) - return cursor.rowcount > 0 + await db.execute("BEGIN") + try: + cursor = await db.execute("DELETE FROM embedding_models WHERE name = ?", (name,)) + deleted = cursor.rowcount > 0 + + if deleted: + # if we deleted the default model (or the last default), pick a new one + await db.execute( + """ + UPDATE embedding_models + SET is_default = 1 + WHERE name = (SELECT name FROM embedding_models ORDER BY name LIMIT 1) + AND (SELECT COUNT(*) FROM embedding_models WHERE is_default = 1) = 0 + """ + ) + + await db.execute("COMMIT") + return deleted + except Exception: + await db.execute("ROLLBACK") + raise return await self._execute_with_connection(_delete) diff --git a/tests/integration/test_auto_default_logic.py b/tests/integration/test_auto_default_logic.py new file mode 100644 index 0000000..e74d6d8 --- /dev/null +++ b/tests/integration/test_auto_default_logic.py @@ -0,0 +1,107 @@ +import pytest +from lib.entities import LLMModelConfig, LLMProvider, EmbeddingModelConfig +from lib.storage import Storage + +@pytest.mark.asyncio +async def test_llm_auto_default_logic(storage: Storage): + # Clear tables to remove auto-migrated models + await storage._execute_with_connection( + lambda db: db.execute("DELETE FROM llm_models") + ) + + # 1. Test auto-default on first creation + model1 = LLMModelConfig( + name="model1", + provider=LLMProvider.OPENAI, + model_name="gpt-4", + is_default=False # Explicitly False + ) + await storage.save_llm_model(model1) + + saved_model1 = await storage.get_llm_model("model1") + assert saved_model1 is not None + assert saved_model1.is_default is True, "First model should be auto-set to default even if is_default=False" + + # 2. Test adds second model (should NOT be default) + model2 = LLMModelConfig( + name="model2", + provider=LLMProvider.ANTHROPIC, + model_name="claude-3", + is_default=False + ) + await storage.save_llm_model(model2) + + saved_model2 = await storage.get_llm_model("model2") + assert saved_model2.is_default is False + + # Verify model1 is still default + saved_model1 = await storage.get_llm_model("model1") + assert saved_model1.is_default is True + + # 3. Test auto-default on delete to one + # Delete model1 (default), model2 should become default + await storage.delete_llm_model("model1") + + saved_model2 = await storage.get_llm_model("model2") + assert saved_model2.is_default is True, "Remaining single model should become default" + + # 4. Test default reassignment when multiple models exist + # Setup: Create model3, ensure model2 is default. + model3 = LLMModelConfig( + name="model3", + provider=LLMProvider.OLLAMA, + model_name="llama2", + is_default=False + ) + await storage.save_llm_model(model3) + + # model2 is currently default. model3 is not. + m2 = await storage.get_llm_model("model2") + m3 = await storage.get_llm_model("model3") + assert m2.is_default is True + assert m3.is_default is False + + # Delete the current default (model2) + # We expect model3 to become default (since it's the only other one, or alphabetical) + await storage.delete_llm_model("model2") + + saved_model3 = await storage.get_llm_model("model3") + assert saved_model3.is_default is True, "Deleting default model should reassign default to available model" + +@pytest.mark.asyncio +async def test_embedding_auto_default_logic(storage: Storage): + # Clear tables to remove auto-migrated models + await storage._execute_with_connection( + lambda db: db.execute("DELETE FROM embedding_models") + ) + + # 1. Test auto-default on first creation + model1 = EmbeddingModelConfig( + name="emb1", + provider=LLMProvider.OPENAI, + model_name="text-embedding-3-small", + is_default=False + ) + await storage.save_embedding_model(model1) + + saved_model1 = await storage.get_embedding_model("emb1") + assert saved_model1 is not None + assert saved_model1.is_default is True, "First embedding model should be auto-set to default" + + # 2. Add second model + model2 = EmbeddingModelConfig( + name="emb2", + provider=LLMProvider.GEMINI, + model_name="embedding-001", + is_default=False + ) + await storage.save_embedding_model(model2) + + saved_model2 = await storage.get_embedding_model("emb2") + assert saved_model2.is_default is False + + # 3. Test delete to one + await storage.delete_embedding_model("emb1") + + saved_model2 = await storage.get_embedding_model("emb2") + assert saved_model2.is_default is True, "Remaining single embedding model should become default" From ffead9fc2ab77eb5f470eb1836d78bd7457f2222 Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Sat, 31 Jan 2026 17:27:56 +0100 Subject: [PATCH 14/20] =?UTF-8?q?=F0=9F=93=90=20Refactor:=20ran=20make=20f?= =?UTF-8?q?ormat=20and=20pre-merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Settings.tsx | 18 +++-- lib/storage.py | 23 +++--- tests/integration/test_auto_default_logic.py | 62 ++++++++-------- ...est_default_model_selection_integration.py | 70 ++++++++++--------- 4 files changed, 89 insertions(+), 84 deletions(-) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 6d04e9d..1d4ef51 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -251,7 +251,9 @@ export default function Settings() { }, }} > - + @@ -300,7 +302,10 @@ export default function Settings() { - e.stopPropagation()}> + e.stopPropagation()} + > + + )} + + )} @@ -461,7 +476,7 @@ export default function Settings() { }} > - default + Default )} @@ -481,10 +496,11 @@ export default function Settings() { size="small" variant="default" onClick={() => handleSetDefaultEmbedding(model.name)} + disabled={settingDefaultEmbedding === model.name} sx={{ color: "attention.fg", borderColor: "attention.emphasis", - "&:hover": { + "&:hover:not(:disabled)": { bg: "attention.subtle", borderColor: "attention.emphasis", color: "attention.fg", @@ -492,8 +508,12 @@ export default function Settings() { }} > - - Set Default + {settingDefaultEmbedding === model.name ? ( + + ) : ( + + )} + {settingDefaultEmbedding === model.name ? "Setting..." : "Set Default"} From 4b1d483c13fc928e900568bfc44f449e18dc9207 Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Mon, 2 Feb 2026 14:20:57 +0100 Subject: [PATCH 18/20] =?UTF-8?q?=F0=9F=93=90=20Refactor:=20Model=20Card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created a reusable generic component that works with both LLMModelConfig and EmbeddingModelConfig --- .../src/components/settings/ModelCard.tsx | 151 ++++++++ frontend/src/pages/Settings.tsx | 328 ++---------------- 2 files changed, 187 insertions(+), 292 deletions(-) create mode 100644 frontend/src/components/settings/ModelCard.tsx diff --git a/frontend/src/components/settings/ModelCard.tsx b/frontend/src/components/settings/ModelCard.tsx new file mode 100644 index 0000000..4caab8e --- /dev/null +++ b/frontend/src/components/settings/ModelCard.tsx @@ -0,0 +1,151 @@ +import { Box, Text, Button, IconButton, Spinner, Tooltip } from "@primer/react"; +import { + TrashIcon, + PencilIcon, + CheckCircleIcon, + CheckCircleFillIcon, + StarIcon, +} from "@primer/octicons-react"; +import type { LLMModelConfig, EmbeddingModelConfig } from "../../types"; + +interface ModelCardProps { + model: T; + isDefault: boolean; + isTesting: boolean; + isSettingDefault: boolean; + onSetDefault: () => void; + onTest: () => void; + onEdit: () => void; + onDelete: () => void; + extraDetails?: React.ReactNode; +} + +export function ModelCard({ + model, + isDefault, + isTesting, + isSettingDefault, + onSetDefault, + onTest, + onEdit, + onDelete, + extraDetails, +}: ModelCardProps) { + return ( + + + + {/* Name and badges row */} + + {model.name} + + {model.provider} + + {isDefault && ( + + + Default + + )} + + + {/* Model details */} + + model: {model.model_name} + {extraDetails} + + {model.endpoint} + + + {/* Action buttons */} + + {!isDefault && ( + + + + )} + + + + + + + ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index dd6a232..9317fa0 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,21 +1,13 @@ import { useEffect, useState } from "react"; -import { Box, Heading, Text, Button, IconButton, Spinner, Tooltip } from "@primer/react"; -import { - PlusIcon, - TrashIcon, - PencilIcon, - CheckCircleIcon, - CircleIcon, - CheckCircleFillIcon, - StarFillIcon, - StarIcon, -} from "@primer/octicons-react"; +import { Box, Heading, Text, Button, Spinner, Tooltip } from "@primer/react"; +import { PlusIcon, CircleIcon, CheckCircleFillIcon } from "@primer/octicons-react"; import { toast } from "sonner"; import type { LLMModelConfig, EmbeddingModelConfig } from "../types"; import { llmConfigApi } from "../services/llmConfigApi"; import LLMFormModal from "../components/settings/LLMFormModal"; import EmbeddingFormModal from "../components/settings/EmbeddingFormModal"; import { ConfirmModal } from "../components/ui/confirm-modal"; +import { ModelCard } from "../components/settings/ModelCard"; export default function Settings() { const [llmModels, setLlmModels] = useState([]); @@ -242,147 +234,22 @@ export default function Settings() { ) : ( - {llmModels.map((model) => { - const isDefault = model.is_default; - return ( - - - - - - {model.name} - - - {model.provider} - - {isDefault && ( - - - Default - - )} - - - - model: {model.model_name} - - - {model.endpoint} - - - - - - {!isDefault && ( - - - - )} - - { - setEditingLlm(model); - setLlmModalOpen(true); - }} - /> - setDeletingLlm(model.name)} - /> - - - - ); - })} + {llmModels.map((model) => ( + handleSetDefaultLlm(model.name)} + onTest={() => handleTestLlm(model)} + onEdit={() => { + setEditingLlm(model); + setLlmModalOpen(true); + }} + onDelete={() => setDeletingLlm(model.name)} + /> + ))} )} @@ -424,146 +291,23 @@ export default function Settings() { ) : ( - {embeddingModels.map((model) => { - const isDefault = model.is_default; - return ( - - - - - - {model.name} - - - {model.provider} - - {isDefault && ( - - - Default - - )} - - - model: {model.model_name} - {model.dimensions && ` (${model.dimensions}d)`} - - - {model.endpoint} - - - - - {!isDefault && ( - - - - )} - - { - setEditingEmbedding(model); - setEmbeddingModalOpen(true); - }} - /> - setDeletingEmbedding(model.name)} - /> - - - - ); - })} + {embeddingModels.map((model) => ( + handleSetDefaultEmbedding(model.name)} + onTest={() => handleTestEmbedding(model)} + onEdit={() => { + setEditingEmbedding(model); + setEmbeddingModalOpen(true); + }} + onDelete={() => setDeletingEmbedding(model.name)} + extraDetails={model.dimensions ? ` (${model.dimensions}d)` : undefined} + /> + ))} )} From e2ea5ce842f6e3817b60d34046be11a3a9f696c3 Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Mon, 2 Feb 2026 14:55:47 +0100 Subject: [PATCH 19/20] fix coderabbit suggestions --- .../src/components/settings/ModelCard.tsx | 34 +++++---- frontend/src/pages/Settings.tsx | 72 +++++++++++++------ llm/state-frontend.md | 10 ++- 3 files changed, 77 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/settings/ModelCard.tsx b/frontend/src/components/settings/ModelCard.tsx index 4caab8e..f2c92aa 100644 --- a/frontend/src/components/settings/ModelCard.tsx +++ b/frontend/src/components/settings/ModelCard.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { Box, Text, Button, IconButton, Spinner, Tooltip } from "@primer/react"; import { TrashIcon, @@ -8,29 +9,35 @@ import { } from "@primer/octicons-react"; import type { LLMModelConfig, EmbeddingModelConfig } from "../../types"; -interface ModelCardProps { - model: T; +interface ModelCardStatus { isDefault: boolean; isTesting: boolean; isSettingDefault: boolean; +} + +interface ModelCardActions { onSetDefault: () => void; onTest: () => void; onEdit: () => void; onDelete: () => void; - extraDetails?: React.ReactNode; +} + +interface ModelCardProps { + model: T; + status: ModelCardStatus; + actions: ModelCardActions; + extraDetails?: ReactNode; } export function ModelCard({ model, - isDefault, - isTesting, - isSettingDefault, - onSetDefault, - onTest, - onEdit, - onDelete, + status, + actions, extraDetails, }: ModelCardProps) { + const { isDefault, isTesting, isSettingDefault } = status; + const { onSetDefault, onTest, onEdit, onDelete } = actions; + return ( ({ > - {/* Name and badges row */} + {/* name and badges row */} {model.name} ({ > {model.provider} + {/* isDefault renders the default badge with CheckCircleFillIcon to visually distinguish the selected model */} {isDefault && ( ({ )} - {/* Model details */} + {/* model details - model.model_name and model.endpoint; extraDetails may be appended for additional info like embedding dimensions */} model: {model.model_name} {extraDetails} @@ -90,7 +98,7 @@ export function ModelCard({ {model.endpoint} - {/* Action buttons */} + {/* action buttons */} {!isDefault && ( diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 9317fa0..4e0d7c0 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { Box, Heading, Text, Button, Spinner, Tooltip } from "@primer/react"; import { PlusIcon, CircleIcon, CheckCircleFillIcon } from "@primer/octicons-react"; import { toast } from "sonner"; @@ -26,10 +26,16 @@ export default function Settings() { const [langfuseHost, setLangfuseHost] = useState(null); const [loadingLangfuse, setLoadingLangfuse] = useState(true); + const isMountedRef = useRef(true); + useEffect(() => { loadLlmModels(); loadEmbeddingModels(); loadLangfuseStatus(); + + return () => { + isMountedRef.current = false; + }; }, []); const loadLlmModels = async () => { @@ -101,10 +107,13 @@ export default function Settings() { toast.error(`Connection test failed: ${result.message}`); } } catch (error) { + console.error(error); const message = error instanceof Error ? error.message : "Unknown error"; toast.error(`Connection test failed: ${message}`); } finally { - setTestingLlm(null); + if (isMountedRef.current) { + setTestingLlm(null); + } } }; @@ -118,10 +127,13 @@ export default function Settings() { toast.error(`Connection test failed: ${result.message}`); } } catch (error) { + console.error(error); const message = error instanceof Error ? error.message : "Unknown error"; toast.error(`Connection test failed: ${message}`); } finally { - setTestingEmbedding(null); + if (isMountedRef.current) { + setTestingEmbedding(null); + } } }; @@ -133,10 +145,13 @@ export default function Settings() { toast.success("Default LLM model updated"); loadLlmModels(); } catch (error) { + console.error(error); const message = error instanceof Error ? error.message : "Unknown error"; toast.error(`Failed to set default LLM model: ${message}`); } finally { - setSettingDefaultLlm(null); + if (isMountedRef.current) { + setSettingDefaultLlm(null); + } } }; @@ -148,10 +163,13 @@ export default function Settings() { toast.success("Default embedding model updated"); loadEmbeddingModels(); } catch (error) { + console.error(error); const message = error instanceof Error ? error.message : "Unknown error"; toast.error(`Failed to set default embedding model: ${message}`); } finally { - setSettingDefaultEmbedding(null); + if (isMountedRef.current) { + setSettingDefaultEmbedding(null); + } } }; @@ -238,16 +256,20 @@ export default function Settings() { handleSetDefaultLlm(model.name)} - onTest={() => handleTestLlm(model)} - onEdit={() => { - setEditingLlm(model); - setLlmModalOpen(true); + status={{ + isDefault: model.is_default ?? false, + isTesting: testingLlm === model.name, + isSettingDefault: settingDefaultLlm === model.name, + }} + actions={{ + onSetDefault: () => handleSetDefaultLlm(model.name), + onTest: () => handleTestLlm(model), + onEdit: () => { + setEditingLlm(model); + setLlmModalOpen(true); + }, + onDelete: () => setDeletingLlm(model.name), }} - onDelete={() => setDeletingLlm(model.name)} /> ))} @@ -295,16 +317,20 @@ export default function Settings() { handleSetDefaultEmbedding(model.name)} - onTest={() => handleTestEmbedding(model)} - onEdit={() => { - setEditingEmbedding(model); - setEmbeddingModalOpen(true); + status={{ + isDefault: model.is_default ?? false, + isTesting: testingEmbedding === model.name, + isSettingDefault: settingDefaultEmbedding === model.name, + }} + actions={{ + onSetDefault: () => handleSetDefaultEmbedding(model.name), + onTest: () => handleTestEmbedding(model), + onEdit: () => { + setEditingEmbedding(model); + setEmbeddingModalOpen(true); + }, + onDelete: () => setDeletingEmbedding(model.name), }} - onDelete={() => setDeletingEmbedding(model.name)} extraDetails={model.dimensions ? ` (${model.dimensions}d)` : undefined} /> ))} diff --git a/llm/state-frontend.md b/llm/state-frontend.md index 02201de..2106187 100644 --- a/llm/state-frontend.md +++ b/llm/state-frontend.md @@ -30,6 +30,7 @@ frontend/src/ StartEndNode.tsx # circular start/end utils.ts # format conversion settings/ + ModelCard.tsx # reusable model card (status, actions objects) LLMFormModal.tsx # llm config form EmbeddingFormModal.tsx # embedding config form ui/ # shadcn components @@ -69,11 +70,14 @@ frontend/src/ - view stability: tracks by ID, single mode preserves current record ### Settings.tsx -- LLM/embedding model management +- LLM/embedding model management via ModelCard components - provider/model selection (OpenAI, Anthropic, Ollama, etc) - API key configuration -- connection testing -- default model selection +- connection testing with loading states +- explicit "Set Default" button per model (shows spinner while setting) +- default model badge with CheckCircleFillIcon for visual distinction +- mounted guards in async handlers to prevent state updates on unmount +- console.error logging before toast.error for debugging ## components From 1a7d4aee37a7043bafb56f3e02c21b52c22be657 Mon Sep 17 00:00:00 2001 From: Ledjo Lleshaj Date: Mon, 2 Feb 2026 17:55:59 +0100 Subject: [PATCH 20/20] last coderabbit fixes --- frontend/src/pages/Settings.tsx | 18 +++++++++++++----- llm/state-frontend.md | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 4e0d7c0..cc5076f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -41,7 +41,9 @@ export default function Settings() { const loadLlmModels = async () => { try { const models = await llmConfigApi.listLLMModels(); - setLlmModels(models); + if (isMountedRef.current) { + setLlmModels(models); + } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; toast.error(`Failed to load LLM models: ${message}`); @@ -51,7 +53,9 @@ export default function Settings() { const loadEmbeddingModels = async () => { try { const models = await llmConfigApi.listEmbeddingModels(); - setEmbeddingModels(models); + if (isMountedRef.current) { + setEmbeddingModels(models); + } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; toast.error(`Failed to load embedding models: ${message}`); @@ -65,13 +69,17 @@ export default function Settings() { throw new Error(`http ${res.status}`); } const data = await res.json(); - setLangfuseEnabled(data.enabled); - setLangfuseHost(data.host); + if (isMountedRef.current) { + setLangfuseEnabled(data.enabled); + setLangfuseHost(data.host); + } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; console.error("Failed to load Langfuse status:", message); } finally { - setLoadingLangfuse(false); + if (isMountedRef.current) { + setLoadingLangfuse(false); + } } }; diff --git a/llm/state-frontend.md b/llm/state-frontend.md index 2106187..be4f406 100644 --- a/llm/state-frontend.md +++ b/llm/state-frontend.md @@ -71,7 +71,7 @@ frontend/src/ ### Settings.tsx - LLM/embedding model management via ModelCard components -- provider/model selection (OpenAI, Anthropic, Ollama, etc) +- provider/model selection (OpenAI, Anthropic, Ollama, etc.) - API key configuration - connection testing with loading states - explicit "Set Default" button per model (shows spinner while setting)