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():