feat(providers): Allow plugins to add and override model providers

linuztx committed Mar 12, 2026 at 08:29 UTC 342a9584cc0b5ea4f852513b8e702b05bf5ae240
2 files changed +55 -22
helpers/plugins.py
+2
@@ -79,6 +79,8 @@ class PluginListItem(BaseModel):
79
80 def after_plugin_change(plugin_names: list[str] | None = None):
81 clear_plugin_cache()
82 + from helpers.providers import reload_providers
83 + reload_providers()
84 send_frontend_reload_notification(plugin_names)
85
86
helpers/providers.py
+53 -22
@@ -20,39 +20,65 @@ class ProviderManager:
20 cls._instance = cls()
21 return cls._instance
22
23 + @classmethod
24 + def reload(cls):
25 + """Force reload of all provider configs (call after plugin changes)."""
26 + inst = cls.get_instance()
27 + inst._load_providers()
28 +
29 def __init__(self):
30 if self._raw is None or self._options is None:
31 self._load_providers()
32
27 - def _load_providers(self):
28 - """Loads provider configurations from the YAML file and normalises them."""
33 + @staticmethod
34 + def _load_yaml(path: str) -> dict:
35 try:
30 - config_path = files.get_abs_path("conf/model_providers.yaml")
31 - with open(config_path, "r", encoding="utf-8") as f:
32 - raw_yaml = yaml.safe_load(f) or {}
36 + with open(path, "r", encoding="utf-8") as f:
37 + return yaml.safe_load(f) or {}
38 except (FileNotFoundError, yaml.YAMLError):
34 - raw_yaml = {}
35 -
36 - # ------------------------------------------------------------
37 - # Normalise the YAML so that internally we always work with a
38 - # list-of-dicts [{id, name, ...}] for each provider type. This
39 - # keeps existing callers unchanged while allowing the new nested
40 - # mapping format in the YAML (id -> { ... }).
41 - # ------------------------------------------------------------
42 - normalised: Dict[str, List[Dict[str, str]]] = {}
39 + return {}
40
41 + @staticmethod
42 + def _normalise_yaml(raw_yaml: dict) -> Dict[str, Dict[str, Dict[str, str]]]:
43 + """Normalise YAML into {type: {id: config}} mapping format."""
44 + result: Dict[str, Dict[str, Dict[str, str]]] = {}
45 for p_type, providers in (raw_yaml or {}).items():
45 - items: List[Dict[str, str]] = []
46 -
46 + entries: Dict[str, Dict[str, str]] = {}
47 if isinstance(providers, dict):
48 - # New format: mapping of id -> config
48 for pid, cfg in providers.items():
50 - entry = {"id": pid, **(cfg or {})}
51 - items.append(entry)
49 + entries[pid] = cfg or {}
50 elif isinstance(providers, list):
53 - # Legacy list format – use as-is
54 - items.extend(providers or [])
51 + for p in (providers or []):
52 + pid = (p.get("id") or p.get("value") or "").lower()
53 + if pid:
54 + entries[pid] = {k: v for k, v in p.items() if k not in ("id", "value")}
55 + result[p_type] = entries
56 + return result
57
58 + def _load_providers(self):
59 + """Loads provider configs from main YAML and enabled plugins, then merges."""
60 + # Load base config
61 + base_path = files.get_abs_path("conf/model_providers.yaml")
62 + merged = self._normalise_yaml(self._load_yaml(base_path))
63 +
64 + # Merge plugin provider configs (enabled plugins only)
65 + from helpers.plugins import get_enabled_plugin_paths
66 + plugin_yamls = get_enabled_plugin_paths(None, "conf", "model_providers.yaml")
67 + for plugin_yaml_path in plugin_yamls:
68 + plugin_data = self._normalise_yaml(self._load_yaml(plugin_yaml_path))
69 + for p_type, providers in plugin_data.items():
70 + if p_type not in merged:
71 + merged[p_type] = {}
72 + # Overwrite matching keys, append new ones
73 + merged[p_type].update(providers)
74 +
75 + # Convert merged {type: {id: config}} to normalised list format
76 + normalised: Dict[str, List[Dict[str, str]]] = {}
77 + for p_type, providers in merged.items():
78 + items: List[Dict[str, str]] = []
79 + for pid, cfg in providers.items():
80 + entry = {"id": pid, **cfg}
81 + items.append(entry)
82 normalised[p_type] = items
83
84 # Save raw
@@ -98,4 +124,9 @@ def get_raw_providers(provider_type: ModelType) -> List[Dict[str, str]]:
124
125 def get_provider_config(provider_type: ModelType, provider_id: str) -> Optional[Dict[str, str]]:
126 """Return metadata for a single provider (None if not found)."""
101 - return ProviderManager.get_instance().get_provider_config(provider_type, provider_id)
\ No newline at end of file
127 + return ProviderManager.get_instance().get_provider_config(provider_type, provider_id)
128 +
129 +
130 +def reload_providers():
131 + """Re-merge base + plugin provider configs. Call after plugin changes."""
132 + ProviderManager.reload()
\ No newline at end of file