44in the UI. Every dropdown (embedding, generation, reranker) MUST use this endpoint.
55
66NO HARDCODED MODEL LISTS ANYWHERE ELSE.
7+
8+ Note:
9+ - The catalog is primarily static (data/models.json), but we may *augment* it at
10+ request time with config-derived, runtime-only models (e.g. ragweld) so the UI
11+ stays "lawful" without special-casing.
712"""
13+
814import json
15+ import logging
916from pathlib import Path
1017from typing import Any
1118
12- from fastapi import APIRouter , HTTPException
19+ from fastapi import APIRouter , Depends , HTTPException
20+
21+ from server .models .tribrid_config_model import CorpusScope
22+ from server .services .config_store import CorpusNotFoundError
23+ from server .services .config_store import get_config as load_scoped_config
1324
1425router = APIRouter (prefix = "/api/models" , tags = ["models" ])
1526
1627MODELS_PATH = Path (__file__ ).parent .parent .parent / "data" / "models.json"
1728
29+ logger = logging .getLogger (__name__ )
30+
31+ # Ruff B008: avoid function calls in argument defaults (FastAPI Depends()).
32+ _CORPUS_SCOPE_DEP = Depends ()
33+
34+
35+ async def _resolve_ragweld_base_model (scope : CorpusScope | None ) -> str | None :
36+ repo_id : str | None = None
37+ try :
38+ repo_id = scope .resolved_repo_id if scope is not None else None
39+ except Exception :
40+ repo_id = None
41+
42+ cfg = None
43+ if repo_id :
44+ try :
45+ cfg = await load_scoped_config (repo_id = repo_id )
46+ except CorpusNotFoundError as e :
47+ # Don't break clients if a UI passes a stale corpus id.
48+ logger .warning ("models catalog: corpus not found for scope repo_id=%s (%s)" , repo_id , e )
49+ cfg = None
50+ except Exception as e :
51+ logger .warning ("models catalog: failed to load scoped config for repo_id=%s (%s)" , repo_id , e )
52+ cfg = None
53+
54+ if cfg is None :
55+ try :
56+ cfg = await load_scoped_config (repo_id = None )
57+ except Exception as e :
58+ logger .warning ("models catalog: failed to load global config (%s)" , e )
59+ return None
60+
61+ base = str (getattr (getattr (cfg , "training" , None ), "ragweld_agent_base_model" , "" ) or "" ).strip ()
62+ if base .startswith ("ragweld:" ):
63+ base = base .split (":" , 1 )[1 ].strip ()
64+ return base or None
65+
66+
67+ def _augment_catalog_with_ragweld (catalog : dict [str , Any ], ragweld_base_model : str | None ) -> dict [str , Any ]:
68+ if not ragweld_base_model :
69+ return catalog
70+
71+ model_id = f"ragweld:{ ragweld_base_model } "
72+ models = catalog .get ("models" )
73+ if not isinstance (models , list ):
74+ models = []
75+ catalog ["models" ] = models
76+
77+ for m in models :
78+ if isinstance (m , dict ) and str (m .get ("model" ) or "" ) == model_id :
79+ return catalog
80+
81+ models .append (
82+ {
83+ "provider" : "ragweld" ,
84+ "family" : ragweld_base_model ,
85+ "model" : model_id ,
86+ "components" : ["GEN" ],
87+ # Required by /api/models contract tests; 0 means "unknown" (UI treats it as falsy).
88+ "context" : 0 ,
89+ "unit" : "1k_tokens" ,
90+ "input_per_1k" : 0.0 ,
91+ "output_per_1k" : 0.0 ,
92+ "notes" : "Ragweld in-process MLX model (Qwen3 base + hot-swappable LoRA adapter; context unknown)" ,
93+ }
94+ )
95+ return catalog
96+
1897
1998def _load_catalog () -> dict [str , Any ]:
2099 """Load the full models.json catalog.
@@ -41,18 +120,20 @@ def _catalog_models(catalog: dict[str, Any]) -> list[dict[str, Any]]:
41120
42121
43122@router .get ("" )
44- async def get_all_models () -> dict [str , Any ]:
123+ async def get_all_models (scope : CorpusScope = _CORPUS_SCOPE_DEP ) -> dict [str , Any ]:
45124 """
46125 Return the full models.json catalog (metadata + models list).
47126
48127 This is THE source of truth for all model selection in the UI.
49128 Every dropdown (embedding, generation, reranker) MUST use this endpoint.
50129 """
51- return _load_catalog ()
130+ catalog = _load_catalog ()
131+ base = await _resolve_ragweld_base_model (scope )
132+ return _augment_catalog_with_ragweld (catalog , base )
52133
53134
54135@router .get ("/by-type/{component_type}" )
55- async def get_models_by_type (component_type : str ) -> list [dict [str , Any ]]:
136+ async def get_models_by_type (component_type : str , scope : CorpusScope = _CORPUS_SCOPE_DEP ) -> list [dict [str , Any ]]:
56137 """
57138 Return models filtered by component type.
58139
@@ -63,6 +144,8 @@ async def get_models_by_type(component_type: str) -> list[dict[str, Any]]:
63144 List of models that support the given component type
64145 """
65146 catalog = _load_catalog ()
147+ base = await _resolve_ragweld_base_model (scope )
148+ _augment_catalog_with_ragweld (catalog , base )
66149 models = _catalog_models (catalog )
67150 comp = component_type .upper ()
68151 if comp not in ("EMB" , "GEN" , "RERANK" ):
@@ -71,17 +154,21 @@ async def get_models_by_type(component_type: str) -> list[dict[str, Any]]:
71154
72155
73156@router .get ("/providers" )
74- async def get_providers () -> list [str ]:
157+ async def get_providers (scope : CorpusScope = _CORPUS_SCOPE_DEP ) -> list [str ]:
75158 """Return unique list of providers, sorted alphabetically."""
76159 catalog = _load_catalog ()
160+ base = await _resolve_ragweld_base_model (scope )
161+ _augment_catalog_with_ragweld (catalog , base )
77162 models = _catalog_models (catalog )
78163 providers = sorted (set (str (m .get ("provider" , "unknown" )) for m in models ))
79164 return providers
80165
81166
82167@router .get ("/providers/{provider}" )
83- async def get_models_for_provider (provider : str ) -> list [dict [str , Any ]]:
168+ async def get_models_for_provider (provider : str , scope : CorpusScope = _CORPUS_SCOPE_DEP ) -> list [dict [str , Any ]]:
84169 """Return all models for a specific provider."""
85170 catalog = _load_catalog ()
171+ base = await _resolve_ragweld_base_model (scope )
172+ _augment_catalog_with_ragweld (catalog , base )
86173 models = _catalog_models (catalog )
87174 return [m for m in models if m .get ("provider" , "" ).lower () == provider .lower ()]
0 commit comments