feat: add _model_config plugin with call-time model resolution
keyboardstaff committed
Mar 14, 2026 at 09:41 UTC
d570c629c2f493b74b3b31edb0b1919a8c1258f5
19 files changed
+793
-207
agent.py
+27
-37
@@ -309,16 +309,9 @@ class AgentContext:
309
310
@dataclass
311
class AgentConfig:
312
- chat_model: models.ModelConfig
313
- utility_model: models.ModelConfig
314
- embeddings_model: models.ModelConfig
315
- browser_model: models.ModelConfig
312
mcp_servers: str
313
profile: str = ""
314
knowledge_subdirs: list[str] = field(default_factory=lambda: ["default", "custom"])
319
- browser_http_headers: dict[str, str] = field(
320
- default_factory=dict
321
- ) # Custom HTTP headers for browser requests
315
additional: Dict[str, Any] = field(default_factory=dict)
316
317
@@ -713,39 +706,19 @@ class Agent:
706
707
@extension.extensible
708
def get_chat_model(self):
716
- return models.get_chat_model(
717
- self.config.chat_model.provider,
718
- self.config.chat_model.name,
719
- model_config=self.config.chat_model,
720
- **self.config.chat_model.build_kwargs(),
721
- )
709
+ return None
710
711
@extension.extensible
712
def get_utility_model(self):
725
- return models.get_chat_model(
726
- self.config.utility_model.provider,
727
- self.config.utility_model.name,
728
- model_config=self.config.utility_model,
729
- **self.config.utility_model.build_kwargs(),
730
- )
713
+ return None
714
715
@extension.extensible
716
def get_browser_model(self):
734
- return models.get_browser_model(
735
- self.config.browser_model.provider,
736
- self.config.browser_model.name,
737
- model_config=self.config.browser_model,
738
- **self.config.browser_model.build_kwargs(),
739
- )
717
+ return None
718
719
@extension.extensible
720
def get_embedding_model(self):
743
- return models.get_embedding_model(
744
- self.config.embeddings_model.provider,
745
- self.config.embeddings_model.name,
746
- model_config=self.config.embeddings_model,
747
- **self.config.embeddings_model.build_kwargs(),
748
- )
721
+ return None
722
723
@extension.extensible
724
async def call_utility_model(
@@ -803,15 +776,32 @@ class Agent:
776
# model class
777
model = self.get_chat_model()
778
779
+ # call extensions before
780
+ call_data = {
781
+ "model": model,
782
+ "messages": messages,
783
+ "response_callback": response_callback,
784
+ "reasoning_callback": reasoning_callback,
785
+ "background": background,
786
+ "explicit_caching": explicit_caching,
787
+ }
788
+ await extension.call_extensions_async(
789
+ "chat_model_call_before", self, call_data=call_data
790
+ )
791
+
792
# call model
807
- response, reasoning = await model.unified_call(
808
- messages=messages,
809
- reasoning_callback=reasoning_callback,
810
- response_callback=response_callback,
793
+ response, reasoning = await call_data["model"].unified_call(
794
+ messages=call_data["messages"],
795
+ reasoning_callback=call_data["reasoning_callback"],
796
+ response_callback=call_data["response_callback"],
797
rate_limiter_callback=(
812
- self.rate_limiter_callback if not background else None
798
+ self.rate_limiter_callback if not call_data["background"] else None
799
),
814
- explicit_caching=explicit_caching,
800
+ explicit_caching=call_data["explicit_caching"],
801
+ )
802
+
803
+ await extension.call_extensions_async(
804
+ "chat_model_call_after", self, call_data=call_data, response=response, reasoning=reasoning
805
)
806
807
return response, reasoning
helpers/history.py
+11
-5
@@ -159,10 +159,13 @@ class Topic(Record):
159
return self.summary
160
161
def compress_large_messages(self, message_ratio: float = CURRENT_TOPIC_RATIO * LARGE_MESSAGE_TO_CURRENT_TOPIC_RATIO) -> bool:
162
- set = settings.get_settings()
162
+ from plugins._model_config.helpers.model_config import get_chat_model_config
163
+ chat_cfg = get_chat_model_config()
164
+ ctx_length = int(chat_cfg.get("ctx_length", 128000))
165
+ ctx_history = float(chat_cfg.get("ctx_history", 0.7))
166
msg_max_size = (
164
- set["chat_model_ctx_length"]
165
- * set["chat_model_ctx_history"]
167
+ ctx_length
168
+ * ctx_history
169
* message_ratio
170
)
171
large_msgs = []
@@ -479,8 +482,11 @@ def deserialize_history(json_data: str, agent) -> History:
482
483
484
def _get_ctx_size_for_history() -> int:
482
- set = settings.get_settings()
483
- return int(set["chat_model_ctx_length"] * set["chat_model_ctx_history"])
485
+ from plugins._model_config.helpers.model_config import get_chat_model_config
486
+ chat_cfg = get_chat_model_config()
487
+ ctx_length = int(chat_cfg.get("ctx_length", 128000))
488
+ ctx_history = float(chat_cfg.get("ctx_history", 0.7))
489
+ return int(ctx_length * ctx_history)
490
491
492
def _stringify_output(output: OutputMessage, ai_label="ai", human_label="human"):
helpers/settings.py
-88
@@ -53,44 +53,6 @@ def get_default_value(name: str, value: T) -> T:
53
class Settings(TypedDict):
54
version: str
55
56
- chat_model_provider: str
57
- chat_model_name: str
58
- chat_model_api_base: str
59
- chat_model_kwargs: dict[str, Any]
60
- chat_model_ctx_length: int
61
- chat_model_ctx_history: float
62
- chat_model_vision: bool
63
- chat_model_rl_requests: int
64
- chat_model_rl_input: int
65
- chat_model_rl_output: int
66
-
67
- util_model_provider: str
68
- util_model_name: str
69
- util_model_api_base: str
70
- util_model_kwargs: dict[str, Any]
71
- util_model_ctx_length: int
72
- util_model_ctx_input: float
73
- util_model_rl_requests: int
74
- util_model_rl_input: int
75
- util_model_rl_output: int
76
-
77
- embed_model_provider: str
78
- embed_model_name: str
79
- embed_model_api_base: str
80
- embed_model_kwargs: dict[str, Any]
81
- embed_model_rl_requests: int
82
- embed_model_rl_input: int
83
-
84
- browser_model_provider: str
85
- browser_model_name: str
86
- browser_model_api_base: str
87
- browser_model_vision: bool
88
- browser_model_rl_requests: int
89
- browser_model_rl_input: int
90
- browser_model_rl_output: int
91
- browser_model_kwargs: dict[str, Any]
92
- browser_http_headers: dict[str, Any]
93
-
56
agent_profile: str
57
agent_knowledge_subdir: str
58
@@ -261,10 +223,6 @@ def convert_out(settings: Settings) -> SettingsOutput:
223
),
224
}
225
264
- additional["chat_providers"] = _ensure_option_present(additional.get("chat_providers"), current.get("chat_model_provider"))
265
- additional["chat_providers"] = _ensure_option_present(additional.get("chat_providers"), current.get("util_model_provider"))
266
- additional["chat_providers"] = _ensure_option_present(additional.get("chat_providers"), current.get("browser_model_provider"))
267
- additional["embedding_providers"] = _ensure_option_present(additional.get("embedding_providers"), current.get("embed_model_provider"))
226
additional["agent_subdirs"] = _ensure_option_present(additional.get("agent_subdirs"), current.get("agent_profile"))
227
additional["knowledge_subdirs"] = _ensure_option_present(additional.get("knowledge_subdirs"), current.get("agent_knowledge_subdir"))
228
additional["stt_models"] = _ensure_option_present(additional.get("stt_models"), current.get("stt_model_size"))
@@ -493,40 +451,6 @@ def get_default_settings() -> Settings:
451
gitignore = files.read_file(files.get_abs_path("conf/workdir.gitignore"))
452
return Settings(
453
version=_get_version(),
496
- chat_model_provider=get_default_value("chat_model_provider", "openrouter"),
497
- chat_model_name=get_default_value("chat_model_name", "anthropic/claude-sonnet-4.6"),
498
- chat_model_api_base=get_default_value("chat_model_api_base", ""),
499
- chat_model_kwargs=get_default_value("chat_model_kwargs", {}),
500
- chat_model_ctx_length=get_default_value("chat_model_ctx_length", 100000),
501
- chat_model_ctx_history=get_default_value("chat_model_ctx_history", 0.7),
502
- chat_model_vision=get_default_value("chat_model_vision", True),
503
- chat_model_rl_requests=get_default_value("chat_model_rl_requests", 0),
504
- chat_model_rl_input=get_default_value("chat_model_rl_input", 0),
505
- chat_model_rl_output=get_default_value("chat_model_rl_output", 0),
506
- util_model_provider=get_default_value("util_model_provider", "openrouter"),
507
- util_model_name=get_default_value("util_model_name", "google/gemini-3-flash-preview"),
508
- util_model_api_base=get_default_value("util_model_api_base", ""),
509
- util_model_ctx_length=get_default_value("util_model_ctx_length", 100000),
510
- util_model_ctx_input=get_default_value("util_model_ctx_input", 0.7),
511
- util_model_kwargs=get_default_value("util_model_kwargs", {}),
512
- util_model_rl_requests=get_default_value("util_model_rl_requests", 0),
513
- util_model_rl_input=get_default_value("util_model_rl_input", 0),
514
- util_model_rl_output=get_default_value("util_model_rl_output", 0),
515
- embed_model_provider=get_default_value("embed_model_provider", "huggingface"),
516
- embed_model_name=get_default_value("embed_model_name", "sentence-transformers/all-MiniLM-L6-v2"),
517
- embed_model_api_base=get_default_value("embed_model_api_base", ""),
518
- embed_model_kwargs=get_default_value("embed_model_kwargs", {}),
519
- embed_model_rl_requests=get_default_value("embed_model_rl_requests", 0),
520
- embed_model_rl_input=get_default_value("embed_model_rl_input", 0),
521
- browser_model_provider=get_default_value("browser_model_provider", "openrouter"),
522
- browser_model_name=get_default_value("browser_model_name", "anthropic/claude-sonnet-4.6"),
523
- browser_model_api_base=get_default_value("browser_model_api_base", ""),
524
- browser_model_vision=get_default_value("browser_model_vision", True),
525
- browser_model_rl_requests=get_default_value("browser_model_rl_requests", 0),
526
- browser_model_rl_input=get_default_value("browser_model_rl_input", 0),
527
- browser_model_rl_output=get_default_value("browser_model_rl_output", 0),
528
- browser_model_kwargs=get_default_value("browser_model_kwargs", {}),
529
- browser_http_headers=get_default_value("browser_http_headers", {}),
454
api_keys={},
455
auth_login="",
456
auth_password="",
@@ -587,18 +511,6 @@ def _apply_settings(previous: Settings | None):
511
whisper.preload, _settings["stt_model_size"]
512
) # TODO overkill, replace with background task
513
590
- # notify plugins of embedding model change
591
- if not previous or (
592
- _settings["embed_model_name"] != previous["embed_model_name"]
593
- or _settings["embed_model_provider"] != previous["embed_model_provider"]
594
- or _settings["embed_model_kwargs"] != previous["embed_model_kwargs"]
595
- ):
596
- from helpers.extension import call_extensions_async
597
-
598
- defer.DeferredTask().start_task(
599
- call_extensions_async, "embedding_model_changed"
600
- )
601
-
514
# update mcp settings if necessary
515
if not previous or _settings["mcp_servers"] != previous["mcp_servers"]:
516
from helpers.mcp_handler import MCPConfig
initialize.py
+1
-69
@@ -1,5 +1,4 @@
1
from agent import AgentConfig
2
-import models
2
from helpers import runtime, settings, defer, extension
3
from helpers.print_style import PrintStyle
4
@@ -10,78 +9,11 @@ def initialize_agent(override_settings: dict | None = None):
9
if override_settings:
10
current_settings = settings.merge_settings(current_settings, override_settings)
11
13
- def _normalize_model_kwargs(kwargs: dict) -> dict:
14
- # convert string values that represent valid Python numbers to numeric types
15
- result = {}
16
- for key, value in kwargs.items():
17
- if isinstance(value, str):
18
- # try to convert string to number if it's a valid Python number
19
- try:
20
- # try int first, then float
21
- result[key] = int(value)
22
- except ValueError:
23
- try:
24
- result[key] = float(value)
25
- except ValueError:
26
- result[key] = value
27
- else:
28
- result[key] = value
29
- return result
30
-
31
- # chat model from user settings
32
- chat_llm = models.ModelConfig(
33
- type=models.ModelType.CHAT,
34
- provider=current_settings["chat_model_provider"],
35
- name=current_settings["chat_model_name"],
36
- api_base=current_settings["chat_model_api_base"],
37
- ctx_length=current_settings["chat_model_ctx_length"],
38
- vision=current_settings["chat_model_vision"],
39
- limit_requests=current_settings["chat_model_rl_requests"],
40
- limit_input=current_settings["chat_model_rl_input"],
41
- limit_output=current_settings["chat_model_rl_output"],
42
- kwargs=_normalize_model_kwargs(current_settings["chat_model_kwargs"]),
43
- )
44
-
45
- # utility model from user settings
46
- utility_llm = models.ModelConfig(
47
- type=models.ModelType.CHAT,
48
- provider=current_settings["util_model_provider"],
49
- name=current_settings["util_model_name"],
50
- api_base=current_settings["util_model_api_base"],
51
- ctx_length=current_settings["util_model_ctx_length"],
52
- limit_requests=current_settings["util_model_rl_requests"],
53
- limit_input=current_settings["util_model_rl_input"],
54
- limit_output=current_settings["util_model_rl_output"],
55
- kwargs=_normalize_model_kwargs(current_settings["util_model_kwargs"]),
56
- )
57
- # embedding model from user settings
58
- embedding_llm = models.ModelConfig(
59
- type=models.ModelType.EMBEDDING,
60
- provider=current_settings["embed_model_provider"],
61
- name=current_settings["embed_model_name"],
62
- api_base=current_settings["embed_model_api_base"],
63
- limit_requests=current_settings["embed_model_rl_requests"],
64
- kwargs=_normalize_model_kwargs(current_settings["embed_model_kwargs"]),
65
- )
66
- # browser model from user settings
67
- browser_llm = models.ModelConfig(
68
- type=models.ModelType.CHAT,
69
- provider=current_settings["browser_model_provider"],
70
- name=current_settings["browser_model_name"],
71
- api_base=current_settings["browser_model_api_base"],
72
- vision=current_settings["browser_model_vision"],
73
- kwargs=_normalize_model_kwargs(current_settings["browser_model_kwargs"]),
74
- )
75
- # agent configuration
12
+ # agent configuration - models are now resolved at call time via _model_config plugin
13
config = AgentConfig(
77
- chat_model=chat_llm,
78
- utility_model=utility_llm,
79
- embeddings_model=embedding_llm,
80
- browser_model=browser_llm,
14
profile=current_settings["agent_profile"],
15
knowledge_subdirs=[current_settings["agent_knowledge_subdir"], "default"],
16
mcp_servers=current_settings["mcp_servers"],
84
- browser_http_headers=current_settings["browser_http_headers"],
17
)
18
19
# update config with runtime args
plugins/_memory/helpers/memory.py
+7
-2
@@ -60,6 +60,11 @@ class Memory:
60
61
index: dict[str, "MyFaiss"] = {}
62
63
+ @staticmethod
64
+ def _get_embedding_config(agent=None):
65
+ from plugins._model_config.helpers.model_config import get_embedding_model_config_object
66
+ return get_embedding_model_config_object(agent)
67
+
68
@staticmethod
69
async def get(agent: Agent):
70
memory_subdir = get_agent_memory_subdir(agent)
@@ -70,7 +75,7 @@ class Memory:
75
)
76
db, created = Memory.initialize(
77
log_item,
73
- agent.config.embeddings_model,
78
+ Memory._get_embedding_config(agent),
79
memory_subdir,
80
False,
81
)
@@ -98,7 +103,7 @@ class Memory:
103
import initialize
104
105
agent_config = initialize.initialize_agent()
101
- model_config = agent_config.embeddings_model
106
+ model_config = Memory._get_embedding_config()
107
db, _created = Memory.initialize(
108
log_item=log_item,
109
model_config=model_config,
plugins/_model_config/api/api_keys.py
new
+60
@@ -0,0 +1,60 @@
1
+from helpers.api import ApiHandler, Request, Response
2
+from helpers import dotenv
3
+import models
4
+
5
+API_KEY_PLACEHOLDER = "************"
6
+
7
+
8
+class ApiKeys(ApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ action = input.get("action", "get") # get | set | reveal
11
+
12
+ if action == "get":
13
+ return self._get_keys()
14
+ elif action == "set":
15
+ return self._set_keys(input)
16
+ elif action == "reveal":
17
+ return self._reveal_key(input)
18
+
19
+ return Response(status=400, response=f"Unknown action: {action}")
20
+
21
+ def _get_keys(self) -> dict:
22
+ from helpers.providers import get_providers
23
+
24
+ providers = get_providers("chat") + get_providers("embedding")
25
+ seen = set()
26
+ keys = {}
27
+
28
+ for p in providers:
29
+ pid = p.get("value", "")
30
+ if pid and pid not in seen:
31
+ seen.add(pid)
32
+ api_key = models.get_api_key(pid)
33
+ has_key = bool(api_key and api_key.strip() and api_key != "None")
34
+ keys[pid] = {
35
+ "label": p.get("label", pid),
36
+ "has_key": has_key,
37
+ "masked": API_KEY_PLACEHOLDER if has_key else "",
38
+ }
39
+
40
+ return {"keys": keys}
41
+
42
+ def _set_keys(self, input: dict) -> dict:
43
+ updates = input.get("keys", {})
44
+ if not isinstance(updates, dict):
45
+ return {"ok": False, "error": "Invalid keys format"}
46
+
47
+ for provider, value in updates.items():
48
+ if isinstance(value, str) and value != API_KEY_PLACEHOLDER:
49
+ dotenv.save_dotenv_value(f"API_KEY_{provider.upper()}", value)
50
+
51
+ return {"ok": True}
52
+
53
+ def _reveal_key(self, input: dict) -> dict:
54
+ provider = input.get("provider", "")
55
+ if not provider:
56
+ return {"ok": False, "error": "Missing provider"}
57
+ api_key = models.get_api_key(provider)
58
+ if api_key and api_key.strip() and api_key != "None":
59
+ return {"ok": True, "value": api_key}
60
+ return {"ok": True, "value": ""}
plugins/_model_config/api/model_config_get.py
new
+41
@@ -0,0 +1,41 @@
1
+from helpers.api import ApiHandler, Request, Response
2
+from helpers import plugins
3
+from plugins._model_config.helpers import model_config
4
+import models
5
+
6
+
7
+class ModelConfigGet(ApiHandler):
8
+ async def process(self, input: dict, request: Request) -> dict | Response:
9
+ project_name = input.get("project_name", "")
10
+ agent_profile = input.get("agent_profile", "")
11
+
12
+ config = model_config.get_config(
13
+ project_name=project_name or None,
14
+ agent_profile=agent_profile or None,
15
+ )
16
+
17
+ # Provide default if no config found
18
+ if not config:
19
+ config = plugins.get_default_plugin_config("_model_config") or {}
20
+
21
+ # Add provider lists for UI dropdowns
22
+ chat_providers = model_config.get_chat_providers()
23
+ embedding_providers = model_config.get_embedding_providers()
24
+
25
+ # Mask API keys - show status only
26
+ api_key_status = {}
27
+ all_providers = chat_providers + embedding_providers
28
+ seen = set()
29
+ for p in all_providers:
30
+ pid = p.get("value", "")
31
+ if pid and pid not in seen:
32
+ seen.add(pid)
33
+ key = models.get_api_key(pid)
34
+ api_key_status[pid] = bool(key and key.strip() and key != "None")
35
+
36
+ return {
37
+ "config": config,
38
+ "chat_providers": chat_providers,
39
+ "embedding_providers": embedding_providers,
40
+ "api_key_status": api_key_status,
41
+ }
plugins/_model_config/api/model_config_set.py
new
+35
@@ -0,0 +1,35 @@
1
+from helpers.api import ApiHandler, Request, Response
2
+from helpers import plugins, defer
3
+from helpers.extension import call_extensions_async
4
+
5
+
6
+class ModelConfigSet(ApiHandler):
7
+ async def process(self, input: dict, request: Request) -> dict | Response:
8
+ project_name = input.get("project_name", "")
9
+ agent_profile = input.get("agent_profile", "")
10
+ config = input.get("config")
11
+
12
+ if not config or not isinstance(config, dict):
13
+ return Response(status=400, response="Missing or invalid config")
14
+
15
+ plugins.save_plugin_config(
16
+ "_model_config",
17
+ project_name=project_name,
18
+ agent_profile=agent_profile,
19
+ settings=config,
20
+ )
21
+
22
+ # Check if embedding model changed and notify
23
+ prev_config = plugins.get_plugin_config("_model_config") or {}
24
+ prev_embed = prev_config.get("embedding_model", {})
25
+ new_embed = config.get("embedding_model", {})
26
+ if (
27
+ prev_embed.get("provider") != new_embed.get("provider")
28
+ or prev_embed.get("name") != new_embed.get("name")
29
+ or prev_embed.get("kwargs") != new_embed.get("kwargs")
30
+ ):
31
+ defer.DeferredTask().start_task(
32
+ call_extensions_async, "embedding_model_changed"
33
+ )
34
+
35
+ return {"ok": True}
plugins/_model_config/api/model_search.py
new
+201
@@ -0,0 +1,201 @@
1
+import httpx
2
+from helpers.api import ApiHandler, Request, Response
3
+from helpers.providers import get_provider_config
4
+import models
5
+
6
+_CLOUD_ENDPOINTS: dict[str, str] = {
7
+ "openai": "https://api.openai.com/v1/models",
8
+ "anthropic": "https://api.anthropic.com/v1/models",
9
+ "groq": "https://api.groq.com/openai/v1/models",
10
+ "deepseek": "https://api.deepseek.com/models",
11
+ "mistral": "https://api.mistral.ai/v1/models",
12
+ "openrouter": "https://openrouter.ai/api/v1/models",
13
+ "xai": "https://api.x.ai/v1/models",
14
+ "sambanova": "https://api.sambanova.ai/v1/models",
15
+ "moonshot": "https://api.moonshot.cn/v1/models",
16
+ "google": "https://generativelanguage.googleapis.com/v1beta/models",
17
+ "a0_venice": "https://api.venice.ai/api/v1/models",
18
+ "venice": "https://api.venice.ai/api/v1/models",
19
+}
20
+
21
+# Local providers with default base URLs (no auth required).
22
+_LOCAL_DEFAULTS: dict[str, str] = {
23
+ "ollama": "http://host.docker.internal:11434",
24
+ "lm_studio": "http://host.docker.internal:1234",
25
+}
26
+
27
+# Providers with hardcoded model lists (no listing API available).
28
+_STATIC_MODELS: dict[str, list[str]] = {
29
+ "github_copilot": [
30
+ "gpt-4.1", "gpt-4o", "gpt-5-mini", "oswe-vscode-prime",
31
+ ],
32
+ "zai": [
33
+ "glm-4-plus", "glm-4-air-250414", "glm-4-airx",
34
+ "glm-4-long", "glm-4-flashx", "glm-4-flash-250414",
35
+ "glm-4v-plus", "glm-4v", "glm-3-turbo",
36
+ ],
37
+ "zai_coding": [
38
+ "codegeex-4",
39
+ "glm-4-plus", "glm-4-air-250414", "glm-4-airx",
40
+ "glm-4-flashx", "glm-4-flash-250414",
41
+ ],
42
+}
43
+
44
+# Model name substrings to exclude from litellm fallback results
45
+_LITELLM_EXCLUDE = frozenset({
46
+ "dall-e", "gpt-image", "tts", "whisper", "audio",
47
+ "realtime", "davinci", "babbage", "ada", "vision-preview",
48
+})
49
+
50
+
51
+class ModelSearch(ApiHandler):
52
+ async def process(self, input: dict, request: Request) -> dict | Response:
53
+ provider = input.get("provider", "")
54
+ query = input.get("query", "").lower()
55
+ model_type = input.get("model_type", "chat")
56
+ user_api_base = input.get("api_base", "")
57
+
58
+ if not provider:
59
+ return {"models": []}
60
+
61
+ if provider in _STATIC_MODELS:
62
+ all_models = list(_STATIC_MODELS[provider])
63
+ else:
64
+ provider_cfg = get_provider_config(model_type, provider)
65
+ all_models = await self._fetch_models(provider, provider_cfg, user_api_base) or []
66
+
67
+ if not all_models:
68
+ litellm_provider = (provider_cfg or {}).get("litellm_provider", provider)
69
+ if litellm_provider == provider:
70
+ all_models = self._litellm_fallback(provider, provider_cfg)
71
+
72
+ if query:
73
+ all_models = [m for m in all_models if query in m.lower()]
74
+
75
+ return {"models": sorted(all_models)[:50], "provider": provider}
76
+
77
+ async def _fetch_models(self, provider: str, cfg: dict | None, user_api_base: str = "") -> list[str] | None:
78
+ api_base = user_api_base or (cfg or {}).get("kwargs", {}).get("api_base", "")
79
+ api_key = models.get_api_key(provider)
80
+
81
+ url, fmt = self._resolve_url(provider, api_base)
82
+ if not url:
83
+ return None
84
+
85
+ headers = self._build_headers(provider, api_key, cfg)
86
+ params: dict[str, str] = {}
87
+ if provider == "google":
88
+ if api_key and api_key != "None":
89
+ params["key"] = api_key
90
+ params["pageSize"] = "1000"
91
+ elif provider == "anthropic":
92
+ params["limit"] = "1000"
93
+ elif provider == "azure":
94
+ params["api-version"] = "2024-10-21"
95
+
96
+ try:
97
+ async with httpx.AsyncClient(timeout=10.0) as client:
98
+ resp = await client.get(url, headers=headers, params=params)
99
+ if resp.status_code == 200:
100
+ result = self._parse(resp.json(), fmt)
101
+ if result:
102
+ return result
103
+ except Exception:
104
+ pass
105
+
106
+ return None
107
+
108
+ def _resolve_url(self, provider: str, api_base: str) -> tuple[str | None, str]:
109
+ if provider == "ollama":
110
+ base = api_base or _LOCAL_DEFAULTS.get("ollama", "")
111
+ return (base.rstrip("/") + "/api/tags" if base else None), "ollama"
112
+
113
+ if provider == "google":
114
+ if api_base:
115
+ return api_base.rstrip("/") + "/models", "google"
116
+ return _CLOUD_ENDPOINTS["google"], "google"
117
+
118
+ if provider == "azure":
119
+ if not api_base:
120
+ return None, "openai"
121
+ return api_base.rstrip("/") + "/openai/models", "openai"
122
+
123
+ if provider in _CLOUD_ENDPOINTS:
124
+ return _CLOUD_ENDPOINTS[provider], "openai"
125
+
126
+ if api_base:
127
+ return api_base.rstrip("/") + "/models", "openai"
128
+
129
+ if provider in _LOCAL_DEFAULTS:
130
+ return _LOCAL_DEFAULTS[provider] + "/v1/models", "openai"
131
+
132
+ return None, "openai"
133
+
134
+ def _build_headers(self, provider: str, api_key: str, cfg: dict | None) -> dict[str, str]:
135
+ headers: dict[str, str] = {}
136
+ has_key = api_key and api_key != "None"
137
+
138
+ if provider == "anthropic":
139
+ if has_key:
140
+ headers["x-api-key"] = api_key
141
+ headers["anthropic-version"] = "2023-06-01"
142
+ elif provider == "google":
143
+ pass
144
+ elif provider == "azure":
145
+ if has_key:
146
+ headers["api-key"] = api_key
147
+ elif provider not in ("ollama", "lm_studio"):
148
+ if has_key:
149
+ headers["Authorization"] = f"Bearer {api_key}"
150
+
151
+ extra = (cfg or {}).get("kwargs", {}).get("extra_headers", {})
152
+ if isinstance(extra, dict):
153
+ for k, v in extra.items():
154
+ if isinstance(v, str):
155
+ headers[k] = v
156
+
157
+ return headers
158
+
159
+ def _litellm_fallback(self, provider: str, cfg: dict | None) -> list[str]:
160
+ try:
161
+ import litellm
162
+ registry = getattr(litellm, "models_by_provider", None)
163
+ if not registry:
164
+ return []
165
+
166
+ litellm_provider = (cfg or {}).get("litellm_provider", provider)
167
+ raw_models: set = registry.get(litellm_provider, set())
168
+ if not raw_models:
169
+ return []
170
+
171
+ prefix = litellm_provider + "/"
172
+ result: list[str] = []
173
+ for name in raw_models:
174
+ clean = name[len(prefix):] if name.startswith(prefix) else name
175
+ low = clean.lower()
176
+ if any(exc in low for exc in _LITELLM_EXCLUDE):
177
+ continue
178
+ if clean:
179
+ result.append(clean)
180
+ return result
181
+ except Exception:
182
+ return []
183
+
184
+ def _parse(self, data: dict | list, fmt: str) -> list[str]:
185
+ if fmt == "ollama":
186
+ return [m.get("name", "") for m in data.get("models", []) if m.get("name")]
187
+
188
+ if fmt == "google":
189
+ result = []
190
+ for m in data.get("models", []):
191
+ name = m.get("name", "")
192
+ if name.startswith("models/"):
193
+ name = name[7:]
194
+ if name:
195
+ result.append(name)
196
+ return result
197
+
198
+ if isinstance(data, dict) and "data" in data:
199
+ return [m.get("id", "") for m in data["data"] if m.get("id")]
200
+
201
+ return []
plugins/_model_config/default_config.yaml
new
+35
@@ -0,0 +1,35 @@
1
+chat_model:
2
+ provider: "openrouter"
3
+ name: "anthropic/claude-sonnet-4.6"
4
+ api_base: ""
5
+ ctx_length: 128000
6
+ ctx_history: 0.7
7
+ vision: true
8
+ rl_requests: 0
9
+ rl_input: 0
10
+ rl_output: 0
11
+ kwargs: {}
12
+ allow_chat_override: false
13
+
14
+utility_model:
15
+ provider: "openrouter"
16
+ name: "google/gemini-3-flash-preview"
17
+ api_base: ""
18
+ ctx_length: 128000
19
+ ctx_input: 0.7
20
+ rl_requests: 0
21
+ rl_input: 0
22
+ rl_output: 0
23
+ kwargs: {}
24
+
25
+embedding_model:
26
+ provider: "huggingface"
27
+ name: "sentence-transformers/all-MiniLM-L6-v2"
28
+ api_base: ""
29
+ rl_requests: 0
30
+ rl_input: 0
31
+ kwargs: {}
32
+
33
+browser_http_headers: {}
34
+
35
+model_presets: []
plugins/_model_config/extensions/python/agent_Agent_get_browser_model_start/_10_model_config.py
new
+8
@@ -0,0 +1,8 @@
1
+from helpers.extension import Extension
2
+from plugins._model_config.helpers.model_config import build_browser_model
3
+
4
+
5
+class BrowserModelProvider(Extension):
6
+ def execute(self, data: dict = {}, **kwargs):
7
+ if self.agent:
8
+ data["result"] = build_browser_model(self.agent)
plugins/_model_config/extensions/python/agent_Agent_get_chat_model_start/_10_model_config.py
new
+8
@@ -0,0 +1,8 @@
1
+from helpers.extension import Extension
2
+from plugins._model_config.helpers.model_config import build_chat_model
3
+
4
+
5
+class ChatModelProvider(Extension):
6
+ def execute(self, data: dict = {}, **kwargs):
7
+ if self.agent:
8
+ data["result"] = build_chat_model(self.agent)
plugins/_model_config/extensions/python/agent_Agent_get_embedding_model_start/_10_model_config.py
new
+8
@@ -0,0 +1,8 @@
1
+from helpers.extension import Extension
2
+from plugins._model_config.helpers.model_config import build_embedding_model
3
+
4
+
5
+class EmbeddingModelProvider(Extension):
6
+ def execute(self, data: dict = {}, **kwargs):
7
+ if self.agent:
8
+ data["result"] = build_embedding_model(self.agent)
plugins/_model_config/extensions/python/agent_Agent_get_utility_model_start/_10_model_config.py
new
+8
@@ -0,0 +1,8 @@
1
+from helpers.extension import Extension
2
+from plugins._model_config.helpers.model_config import build_utility_model
3
+
4
+
5
+class UtilityModelProvider(Extension):
6
+ def execute(self, data: dict = {}, **kwargs):
7
+ if self.agent:
8
+ data["result"] = build_utility_model(self.agent)
plugins/_model_config/extensions/python/initialize_migration_start/_10_migrate_model_config.py
new
+102
@@ -0,0 +1,102 @@
1
+import json
2
+import os
3
+from helpers.extension import Extension
4
+from helpers import settings as settings_helper, files, plugins
5
+from helpers.print_style import PrintStyle
6
+
7
+
8
+class MigrateModelConfig(Extension):
9
+ """
10
+ One-time migration: copy legacy model settings into _model_config plugin config.
11
+ Runs during initialize_migration. Only migrates if no global plugin config exists yet
12
+ and the settings file contains legacy model fields.
13
+ """
14
+
15
+ LEGACY_FIELDS = [
16
+ "chat_model_provider", "chat_model_name", "chat_model_api_base",
17
+ "chat_model_kwargs", "chat_model_ctx_length", "chat_model_vision",
18
+ "chat_model_rl_requests", "chat_model_rl_input", "chat_model_rl_output",
19
+ "chat_model_ctx_history",
20
+ "util_model_provider", "util_model_name", "util_model_api_base",
21
+ "util_model_kwargs", "util_model_ctx_length",
22
+ "util_model_rl_requests", "util_model_rl_input", "util_model_rl_output",
23
+ "util_model_ctx_input",
24
+ "embed_model_provider", "embed_model_name", "embed_model_api_base",
25
+ "embed_model_kwargs", "embed_model_rl_requests", "embed_model_rl_input",
26
+ "browser_model_provider", "browser_model_name", "browser_model_api_base",
27
+ "browser_model_vision", "browser_model_rl_requests", "browser_model_rl_input",
28
+ "browser_model_rl_output", "browser_model_kwargs", "browser_http_headers",
29
+ ]
30
+
31
+ async def execute(self, **kwargs):
32
+ # Check if global plugin config already exists
33
+ global_config_path = files.get_abs_path("plugins/_model_config/config.json")
34
+ if os.path.exists(global_config_path):
35
+ return # already migrated or manually configured
36
+
37
+ # Read raw settings file to check for legacy model fields
38
+ settings_file = files.get_abs_path("usr/settings.json")
39
+ if not os.path.exists(settings_file):
40
+ return
41
+
42
+ try:
43
+ raw = json.loads(files.read_file(settings_file))
44
+ except Exception:
45
+ return
46
+
47
+ # Check if any legacy model field exists in the raw settings
48
+ has_legacy = any(field in raw for field in self.LEGACY_FIELDS)
49
+ if not has_legacy:
50
+ return
51
+
52
+ # Build plugin config from legacy settings
53
+ plugin_config = {
54
+ "chat_model": {
55
+ "provider": raw.get("chat_model_provider", "openrouter"),
56
+ "name": raw.get("chat_model_name", ""),
57
+ "api_base": raw.get("chat_model_api_base", ""),
58
+ "ctx_length": raw.get("chat_model_ctx_length", 128000),
59
+ "ctx_history": raw.get("chat_model_ctx_history", 0.7),
60
+ "vision": raw.get("chat_model_vision", True),
61
+ "rl_requests": raw.get("chat_model_rl_requests", 0),
62
+ "rl_input": raw.get("chat_model_rl_input", 0),
63
+ "rl_output": raw.get("chat_model_rl_output", 0),
64
+ "kwargs": raw.get("chat_model_kwargs", {}),
65
+ "allow_chat_override": False,
66
+ },
67
+ "utility_model": {
68
+ "provider": raw.get("util_model_provider", "openrouter"),
69
+ "name": raw.get("util_model_name", ""),
70
+ "api_base": raw.get("util_model_api_base", ""),
71
+ "ctx_length": raw.get("util_model_ctx_length", 128000),
72
+ "ctx_input": raw.get("util_model_ctx_input", 0.7),
73
+ "rl_requests": raw.get("util_model_rl_requests", 0),
74
+ "rl_input": raw.get("util_model_rl_input", 0),
75
+ "rl_output": raw.get("util_model_rl_output", 0),
76
+ "kwargs": raw.get("util_model_kwargs", {}),
77
+ },
78
+ "embedding_model": {
79
+ "provider": raw.get("embed_model_provider", "huggingface"),
80
+ "name": raw.get("embed_model_name", "sentence-transformers/all-MiniLM-L6-v2"),
81
+ "api_base": raw.get("embed_model_api_base", ""),
82
+ "rl_requests": raw.get("embed_model_rl_requests", 0),
83
+ "rl_input": raw.get("embed_model_rl_input", 0),
84
+ "kwargs": raw.get("embed_model_kwargs", {}),
85
+ },
86
+ "browser_http_headers": raw.get("browser_http_headers", {}),
87
+ }
88
+
89
+ # Ensure kwargs are dicts (might be strings from .env format)
90
+ for section in ["chat_model", "utility_model", "embedding_model"]:
91
+ kw = plugin_config[section].get("kwargs")
92
+ if isinstance(kw, str):
93
+ plugin_config[section]["kwargs"] = {}
94
+
95
+ if isinstance(plugin_config["browser_http_headers"], str):
96
+ plugin_config["browser_http_headers"] = {}
97
+
98
+ # Save as global plugin config
99
+ plugins.save_plugin_config("_model_config", "", "", plugin_config)
100
+ PrintStyle(background_color="#6734C3", font_color="white", padding=True).print(
101
+ "Migrated legacy model settings to _model_config plugin config."
102
+ )
plugins/_model_config/helpers/model_config.py
new
+217
@@ -0,0 +1,217 @@
1
+import models
2
+from helpers import plugins, settings, projects
3
+from helpers.providers import get_providers, get_raw_providers
4
+
5
+
6
+def get_config(agent=None, project_name=None, agent_profile=None):
7
+ """Get the full model config dict for the given agent/scope."""
8
+ return plugins.get_plugin_config(
9
+ "_model_config",
10
+ agent=agent,
11
+ project_name=project_name,
12
+ agent_profile=agent_profile,
13
+ ) or {}
14
+
15
+
16
+def get_presets(agent=None, project_name=None, agent_profile=None) -> list:
17
+ """Get model presets list from config."""
18
+ cfg = get_config(agent, project_name, agent_profile)
19
+ return cfg.get("model_presets", [])
20
+
21
+
22
+def get_preset_by_name(name: str, agent=None) -> dict | None:
23
+ """Find a preset by name."""
24
+ for p in get_presets(agent):
25
+ if p.get("name") == name:
26
+ return p
27
+ return None
28
+
29
+
30
+def _resolve_override(agent) -> dict | None:
31
+ """Resolve the active per-chat override config dict.
32
+ Supports both raw override dicts and preset-based overrides.
33
+ Returns None if no override is active."""
34
+ if not agent:
35
+ return None
36
+ override = agent.context.get_data("chat_model_override")
37
+ if not override:
38
+ return None
39
+
40
+ # If this is a preset reference, resolve it
41
+ if "preset_name" in override:
42
+ preset = get_preset_by_name(override["preset_name"], agent)
43
+ if not preset:
44
+ return None
45
+ return preset
46
+
47
+ return override
48
+
49
+
50
+def get_chat_model_config(agent=None) -> dict:
51
+ """Get chat model config, with per-chat override if active."""
52
+ override = _resolve_override(agent)
53
+ if override:
54
+ # Preset has a nested 'chat' key; raw override is flat
55
+ chat_cfg = override.get("chat", override)
56
+ if chat_cfg.get("provider") or chat_cfg.get("name"):
57
+ return chat_cfg
58
+ cfg = get_config(agent)
59
+ return cfg.get("chat_model", {})
60
+
61
+
62
+def get_utility_model_config(agent=None) -> dict:
63
+ """Get utility model config, with per-chat override if active."""
64
+ override = _resolve_override(agent)
65
+ if override:
66
+ util_cfg = override.get("utility", {})
67
+ if util_cfg.get("provider") or util_cfg.get("name"):
68
+ return util_cfg
69
+ cfg = get_config(agent)
70
+ return cfg.get("utility_model", {})
71
+
72
+
73
+def get_embedding_model_config(agent=None) -> dict:
74
+ """Get embedding model config."""
75
+ cfg = get_config(agent)
76
+ return cfg.get("embedding_model", {})
77
+
78
+
79
+def get_browser_http_headers(agent=None) -> dict:
80
+ """Get browser HTTP headers from config."""
81
+ cfg = get_config(agent)
82
+ return cfg.get("browser_http_headers", {})
83
+
84
+
85
+def is_chat_override_allowed(agent=None) -> bool:
86
+ """Check if per-chat model override is enabled."""
87
+ cfg = get_config(agent)
88
+ chat_cfg = cfg.get("chat_model", {})
89
+ return bool(chat_cfg.get("allow_chat_override", False))
90
+
91
+
92
+def get_ctx_history(agent=None) -> float:
93
+ """Get the chat model context history ratio."""
94
+ cfg = get_chat_model_config(agent)
95
+ return float(cfg.get("ctx_history", 0.7))
96
+
97
+
98
+def get_ctx_input(agent=None) -> float:
99
+ """Get the utility model context input ratio."""
100
+ cfg = get_utility_model_config(agent)
101
+ return float(cfg.get("ctx_input", 0.7))
102
+
103
+
104
+def _normalize_kwargs(kwargs: dict) -> dict:
105
+ """Convert string values that are valid numbers to numeric types."""
106
+ result = {}
107
+ for key, value in kwargs.items():
108
+ if isinstance(value, str):
109
+ try:
110
+ result[key] = int(value)
111
+ except ValueError:
112
+ try:
113
+ result[key] = float(value)
114
+ except ValueError:
115
+ result[key] = value
116
+ else:
117
+ result[key] = value
118
+ return result
119
+
120
+
121
+def build_model_config(cfg: dict, model_type: models.ModelType) -> models.ModelConfig:
122
+ """Build a ModelConfig from a config dict section."""
123
+ return models.ModelConfig(
124
+ type=model_type,
125
+ provider=cfg.get("provider", ""),
126
+ name=cfg.get("name", ""),
127
+ api_base=cfg.get("api_base", ""),
128
+ ctx_length=int(cfg.get("ctx_length", 0)),
129
+ vision=bool(cfg.get("vision", False)),
130
+ limit_requests=int(cfg.get("rl_requests", 0)),
131
+ limit_input=int(cfg.get("rl_input", 0)),
132
+ limit_output=int(cfg.get("rl_output", 0)),
133
+ kwargs=_normalize_kwargs(cfg.get("kwargs", {})),
134
+ )
135
+
136
+
137
+def build_chat_model(agent=None):
138
+ """Build and return a LiteLLMChatWrapper from config."""
139
+ cfg = get_chat_model_config(agent)
140
+ mc = build_model_config(cfg, models.ModelType.CHAT)
141
+ return models.get_chat_model(
142
+ mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
143
+ )
144
+
145
+
146
+def build_utility_model(agent=None):
147
+ """Build and return a LiteLLMChatWrapper for utility tasks."""
148
+ cfg = get_utility_model_config(agent)
149
+ mc = build_model_config(cfg, models.ModelType.CHAT)
150
+ return models.get_chat_model(
151
+ mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
152
+ )
153
+
154
+
155
+def build_browser_model(agent=None):
156
+ """Build and return a BrowserCompatibleChatWrapper using chat model config."""
157
+ cfg = get_chat_model_config(agent)
158
+ mc = build_model_config(cfg, models.ModelType.CHAT)
159
+ return models.get_browser_model(
160
+ mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
161
+ )
162
+
163
+
164
+def build_embedding_model(agent=None):
165
+ """Build and return an embedding model wrapper."""
166
+ cfg = get_embedding_model_config(agent)
167
+ mc = build_model_config(cfg, models.ModelType.EMBEDDING)
168
+ return models.get_embedding_model(
169
+ mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
170
+ )
171
+
172
+
173
+def get_embedding_model_config_object(agent=None) -> models.ModelConfig:
174
+ """Get a ModelConfig object for embeddings (needed by memory plugin)."""
175
+ cfg = get_embedding_model_config(agent)
176
+ return build_model_config(cfg, models.ModelType.EMBEDDING)
177
+
178
+
179
+def get_chat_providers():
180
+ """Get list of chat providers for UI dropdowns."""
181
+ return get_providers("chat")
182
+
183
+
184
+def get_embedding_providers():
185
+ """Get list of embedding providers for UI dropdowns."""
186
+ return get_providers("embedding")
187
+
188
+
189
+def get_missing_api_key_providers(agent=None) -> list[dict]:
190
+ """Check which configured providers are missing API keys."""
191
+ cfg = get_config(agent)
192
+ missing = []
193
+
194
+ LOCAL_PROVIDERS = {"ollama", "lm_studio"}
195
+ LOCAL_EMBEDDING = {"huggingface"}
196
+
197
+ checks = [
198
+ ("Chat Model", cfg.get("chat_model", {})),
199
+ ("Utility Model", cfg.get("utility_model", {})),
200
+ ("Embedding Model", cfg.get("embedding_model", {})),
201
+ ]
202
+
203
+ for label, model_cfg in checks:
204
+ provider = model_cfg.get("provider", "")
205
+ if not provider:
206
+ continue
207
+ provider_lower = provider.lower()
208
+ if provider_lower in LOCAL_PROVIDERS:
209
+ continue
210
+ if label == "Embedding Model" and provider_lower in LOCAL_EMBEDDING:
211
+ continue
212
+
213
+ api_key = models.get_api_key(provider_lower)
214
+ if not (api_key and api_key.strip() and api_key != "None"):
215
+ missing.append({"model_type": label, "provider": provider})
216
+
217
+ return missing
plugins/_model_config/hooks.py
new
+8
@@ -0,0 +1,8 @@
1
+def save_plugin_config(result=None, settings=None, **kwargs):
2
+ if settings and isinstance(settings, dict):
3
+ # Remove transient UI-only fields before persisting
4
+ settings.pop("_browser_headers_text", None)
5
+ for section in ("chat_model", "utility_model", "embedding_model"):
6
+ if section in settings and isinstance(settings[section], dict):
7
+ settings[section].pop("_kwargs_text", None)
8
+ return settings
plugins/_model_config/plugin.yaml
new
+9
@@ -0,0 +1,9 @@
1
+name: _model_config
2
+title: Model Configuration
3
+description: Manages LLM model selection and configuration for chat, utility, and embedding models. Supports per-project and per-agent overrides with optional per-chat model switching.
4
+version: 1.0.0
5
+always_enabled: true
6
+settings_sections:
7
+ - agent
8
+per_project_config: true
9
+per_agent_config: true
preload.py
+7
-6
@@ -18,16 +18,17 @@ async def preload():
18
19
# preload embedding model
20
async def preload_embedding():
21
- if set["embed_model_provider"].lower() == "huggingface":
22
- try:
23
- # Use the new LiteLLM-based model system
21
+ try:
22
+ from plugins._model_config.helpers.model_config import get_embedding_model_config_object
23
+ emb_cfg = get_embedding_model_config_object()
24
+ if emb_cfg.provider.lower() == "huggingface":
25
emb_mod = models.get_embedding_model(
25
- "huggingface", set["embed_model_name"]
26
+ "huggingface", emb_cfg.name
27
)
28
emb_txt = await emb_mod.aembed_query("test")
29
return emb_txt
29
- except Exception as e:
30
- PrintStyle().error(f"Error in preload_embedding: {e}")
30
+ except Exception as e:
31
+ PrintStyle().error(f"Error in preload_embedding: {e}")
32
33
# preload kokoro tts model if enabled
34
async def preload_kokoro():