main
py 1,351 lines 41.1 KB
Raw
1 from __future__ import annotations
2
3 import json
4 import sys
5 import threading
6 import types
7 from pathlib import Path
8
9 import pytest
10 import yaml
11 from flask import Flask
12
13
14 PROJECT_ROOT = Path(__file__).resolve().parents[1]
15 if str(PROJECT_ROOT) not in sys.path:
16 sys.path.insert(0, str(PROJECT_ROOT))
17
18 sys.modules.setdefault("giturlparse", types.SimpleNamespace(parse=lambda *args, **kwargs: None))
19
20
21 class _DummyObserver:
22 def __init__(self):
23 self._alive = False
24
25 def is_alive(self):
26 return self._alive
27
28 def start(self):
29 self._alive = True
30
31 def stop(self):
32 self._alive = False
33
34 def join(self, *args, **kwargs):
35 return None
36
37 def unschedule_all(self):
38 return None
39
40 def schedule(self, *args, **kwargs):
41 return None
42
43
44 watchdog = types.ModuleType("watchdog")
45 watchdog.observers = types.SimpleNamespace(Observer=_DummyObserver)
46 watchdog.events = types.SimpleNamespace(FileSystemEventHandler=object)
47 sys.modules.setdefault("watchdog", watchdog)
48 sys.modules.setdefault("watchdog.observers", watchdog.observers)
49 sys.modules.setdefault("watchdog.events", watchdog.events)
50
51
52 def _copy_extension_fixture(plugin_dir: Path, relative_path: str) -> None:
53 source = PROJECT_ROOT / "plugins" / "_model_config" / relative_path
54 target = plugin_dir / relative_path
55 target.parent.mkdir(parents=True, exist_ok=True)
56 target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
57
58
59 def _clear_runtime_caches():
60 from helpers import cache, modules
61
62 cache.clear("*(extensions)*")
63 cache.clear("*(plugins)*")
64 modules.purge_namespace("usr.plugins")
65
66
67 def _prepare_a0_tree(monkeypatch, tmp_path: Path):
68 from helpers import files, plugins
69
70 monkeypatch.setattr(files, "_base_dir", str(tmp_path))
71 monkeypatch.setattr(
72 plugins,
73 "call_plugin_hook",
74 lambda plugin_name, hook_name, default=None, **kwargs: default,
75 )
76
77 plugin_dir = tmp_path / "plugins" / "_model_config"
78 plugin_dir.mkdir(parents=True)
79 (plugin_dir / "plugin.yaml").write_text(
80 "name: _model_config\nper_project_config: true\nper_agent_config: true\n",
81 encoding="utf-8",
82 )
83 fallback_path = plugin_dir / "mode_presets_fallback.yaml"
84 fallback_path.write_text(
85 """
86 - name: Default
87 chat:
88 provider: openrouter
89 name: default-chat
90 utility:
91 provider: openrouter
92 name: default-utility
93 embedding:
94 provider: huggingface
95 name: default-embedding
96 - name: Balance
97 chat:
98 provider: openrouter
99 name: balanced-chat
100 """.lstrip(),
101 encoding="utf-8",
102 )
103 (plugin_dir / "default_config.yaml").write_text(
104 """
105 model_preset: Default
106 """.lstrip(),
107 encoding="utf-8",
108 )
109 _copy_extension_fixture(
110 plugin_dir,
111 "extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py",
112 )
113 _copy_extension_fixture(
114 plugin_dir,
115 "extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py",
116 )
117 (tmp_path / "usr" / "plugins").mkdir(parents=True)
118 (tmp_path / "usr" / "projects").mkdir(parents=True)
119 _clear_runtime_caches()
120
121
122 def _add_project_extra_plugin(tmp_path: Path):
123 plugin_dir = tmp_path / "plugins" / "_project_extra"
124 plugin_dir.mkdir(parents=True)
125 (plugin_dir / "plugin.yaml").write_text(
126 "name: _project_extra\n",
127 encoding="utf-8",
128 )
129 load_ext = (
130 plugin_dir
131 / "extensions"
132 / "python"
133 / "_functions"
134 / "helpers"
135 / "projects"
136 / "load_project_extended_data"
137 / "end"
138 / "_20_project_extra.py"
139 )
140 load_ext.parent.mkdir(parents=True, exist_ok=True)
141 load_ext.write_text(
142 """
143 from helpers.extension import Extension
144
145
146 class ProjectExtraLoader(Extension):
147 def execute(self, data: dict = {}, **kwargs):
148 result = data.get("result")
149 if not isinstance(result, dict):
150 result = {}
151 data["result"] = result
152 args = data.get("args") or ()
153 project_name = args[0] if args else data.get("kwargs", {}).get("name", "")
154 result["extra"] = {"loaded_for": str(project_name or ""), "enabled": True}
155 """.lstrip(),
156 encoding="utf-8",
157 )
158 save_ext = (
159 plugin_dir
160 / "extensions"
161 / "python"
162 / "_functions"
163 / "helpers"
164 / "projects"
165 / "save_project_extended_data"
166 / "start"
167 / "_20_project_extra.py"
168 )
169 save_ext.parent.mkdir(parents=True, exist_ok=True)
170 save_ext.write_text(
171 """
172 import json
173 from helpers import files
174 from helpers.extension import Extension
175
176
177 class ProjectExtraSaver(Extension):
178 def execute(self, data: dict = {}, **kwargs):
179 args = data.get("args") or ()
180 call_kwargs = data.get("kwargs") or {}
181 project_name = args[0] if args else call_kwargs.get("name", "")
182 project_data = args[1] if len(args) > 1 else call_kwargs.get("project_data")
183 if not isinstance(project_data, dict) or "extra" not in project_data:
184 return
185 forbidden = {"title", "mcp_servers", "git_token"} & set(project_data)
186 if forbidden:
187 raise AssertionError(f"core/transient keys leaked to extension save: {sorted(forbidden)}")
188 path = files.get_abs_path(
189 "usr",
190 "projects",
191 str(project_name or ""),
192 ".a0proj",
193 "extra_saved.json",
194 )
195 files.write_file(
196 path,
197 json.dumps(
198 {"project": str(project_name or ""), "extra": project_data["extra"]},
199 sort_keys=True,
200 ),
201 )
202 """.lstrip(),
203 encoding="utf-8",
204 )
205 _clear_runtime_caches()
206
207
208 def _add_project_conflict_plugin(tmp_path: Path):
209 plugin_dir = tmp_path / "plugins" / "_project_conflict"
210 plugin_dir.mkdir(parents=True)
211 (plugin_dir / "plugin.yaml").write_text(
212 "name: _project_conflict\n",
213 encoding="utf-8",
214 )
215 load_ext = (
216 plugin_dir
217 / "extensions"
218 / "python"
219 / "_functions"
220 / "helpers"
221 / "projects"
222 / "load_project_extended_data"
223 / "end"
224 / "_30_project_conflict.py"
225 )
226 load_ext.parent.mkdir(parents=True, exist_ok=True)
227 load_ext.write_text(
228 """
229 from helpers.extension import Extension
230
231
232 class ProjectConflictLoader(Extension):
233 def execute(self, data: dict = {}, **kwargs):
234 result = data.get("result")
235 if not isinstance(result, dict):
236 result = {}
237 data["result"] = result
238 result["title"] = "Plugin-owned title"
239 """.lstrip(),
240 encoding="utf-8",
241 )
242 _clear_runtime_caches()
243
244
245 def test_global_presets_require_immutable_default_and_save_behavior(monkeypatch, tmp_path):
246 _prepare_a0_tree(monkeypatch, tmp_path)
247
248 from plugins._model_config.helpers import model_config
249
250 assert model_config.get_presets()[0]["name"] == "Default"
251
252 model_config.save_presets(
253 [
254 model_config.get_presets()[0],
255 {
256 "name": "Global One",
257 "scope": "project",
258 "project_name": "ignored",
259 "chat": {"provider": "openai", "name": "gpt-test", "_kwargs_text": ""},
260 }
261 ]
262 )
263
264 presets = model_config.get_presets()
265 assert presets[0]["name"] == "Default"
266 assert presets[1] == {
267 "name": "Global One",
268 "chat": {"provider": "openai", "name": "gpt-test"},
269 }
270
271 saved_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
272 assert "scope:" not in saved_path.read_text(encoding="utf-8")
273
274 with pytest.raises(ValueError, match="cannot be deleted or renamed"):
275 model_config.save_presets([])
276
277 assert model_config.reset_presets()[0]["name"] == "Default"
278
279
280 def test_project_scope_selects_global_presets_only(monkeypatch, tmp_path):
281 _prepare_a0_tree(monkeypatch, tmp_path)
282
283 from helpers import projects, plugins
284 from plugins._model_config.helpers import model_config
285
286 projects.create_project("demo", {"title": "Demo"})
287 plugins.save_plugin_config("_model_config", "demo", "", {"model_preset": "Balance"})
288
289 assert model_config.get_configured_preset_name(project_name="demo") == "Balance"
290 assert model_config.resolve_preset("Balance", scope="global")["chat"]["name"] == "balanced-chat"
291 assert model_config.resolve_preset("Balance", scope="project", project_name="demo") is None
292 with pytest.raises(ValueError, match="no longer supported"):
293 model_config.save_presets(model_config.get_presets(), project_name="demo")
294
295
296 def test_selected_preset_resolves_complete_runtime_config_from_default(monkeypatch, tmp_path):
297 _prepare_a0_tree(monkeypatch, tmp_path)
298
299 from helpers import plugins
300 from plugins._model_config.helpers import model_config
301
302 plugins.save_plugin_config("_model_config", "", "", {"model_preset": "Balance"})
303 config = model_config.get_config()
304
305 assert config["model_preset"] == "Balance"
306 assert config["chat_model"]["name"] == "balanced-chat"
307 assert config["utility_model"]["name"] == "default-utility"
308 assert config["embedding_model"]["name"] == "default-embedding"
309 assert config["allow_chat_override"] is True
310
311
312 def test_legacy_raw_preset_is_preserved_as_canonical_chat_slot(monkeypatch, tmp_path):
313 _prepare_a0_tree(monkeypatch, tmp_path)
314
315 from plugins._model_config.helpers import model_config
316
317 cleaned = model_config.clean_presets_for_file(
318 [
319 {
320 "name": "Legacy",
321 "provider": "venice",
322 "api_key": "must-not-persist",
323 "kwargs": {"temperature": 0.2},
324 }
325 ]
326 )
327
328 assert cleaned == [
329 {
330 "name": "Legacy",
331 "chat": {
332 "provider": "venice",
333 "kwargs": {"temperature": 0.2},
334 "name": "Legacy",
335 },
336 }
337 ]
338
339
340 def test_fallback_non_default_utility_presets_inherit_advanced_settings():
341 from plugins._model_config.helpers import model_config
342
343 presets_path = (
344 PROJECT_ROOT / "plugins" / "_model_config" / "mode_presets_fallback.yaml"
345 )
346 presets = model_config.parse_preset_collection(
347 presets_path.read_text(encoding="utf-8")
348 )
349
350 assert {
351 preset["name"]: (
352 preset["chat"]["name"],
353 preset["utility"]["name"],
354 preset["chat"]["vision"],
355 )
356 for preset in presets
357 } == {
358 "Default": (
359 "openai/gpt-5.6-terra",
360 "google/gemini-3.1-flash-lite",
361 True,
362 ),
363 "Efficiency": (
364 "z-ai/glm-5.2",
365 "deepseek/deepseek-v4-flash",
366 False,
367 ),
368 "Power": (
369 "openai/gpt-5.6-sol",
370 "openai/gpt-5.6-luna",
371 True,
372 ),
373 }
374
375 for preset in presets:
376 if preset.get("name") == "Default":
377 continue
378 utility = preset.get("utility") or {}
379 assert "ctx_length" not in utility
380 assert "ctx_input" not in utility
381
382
383 @pytest.mark.asyncio
384 async def test_model_presets_api_returns_global_presets_for_project_scope(monkeypatch, tmp_path):
385 _prepare_a0_tree(monkeypatch, tmp_path)
386
387 from helpers import projects
388 from plugins._model_config.api.model_presets import ModelPresets
389 from plugins._model_config.helpers import model_config
390
391 projects.create_project("demo", {"title": "Demo"})
392 presets = model_config.get_presets()
393 presets.append({"name": "Global", "chat": {"provider": "global", "name": "chat"}})
394 model_config.save_presets(presets)
395
396 handler = ModelPresets(Flask(__name__), threading.Lock())
397 global_response = await handler.process({"action": "get"}, None)
398 assert [preset["name"] for preset in global_response["presets"]] == [
399 "Default",
400 "Balance",
401 "Global",
402 ]
403
404 project_response = await handler.process({"action": "get", "project_name": "demo"}, None)
405 assert [p["name"] for p in project_response["presets"]] == [
406 "Default",
407 "Balance",
408 "Global",
409 ]
410 assert project_response["project_presets"] == []
411
412
413 @pytest.mark.asyncio
414 async def test_model_presets_api_saves_only_scoped_selection(monkeypatch, tmp_path):
415 _prepare_a0_tree(monkeypatch, tmp_path)
416
417 from helpers import projects
418 from plugins._model_config.api.model_presets import ModelPresets
419
420 projects.create_project("demo", {"title": "Demo"})
421 handler = ModelPresets(Flask(__name__), threading.Lock())
422
423 response = await handler.process(
424 {
425 "action": "select",
426 "name": "Balance",
427 "project_name": "demo",
428 },
429 None,
430 )
431
432 assert response == {"ok": True, "selected_preset": "Balance"}
433 config_path = (
434 tmp_path
435 / "usr"
436 / "projects"
437 / "demo"
438 / ".a0proj"
439 / "plugins"
440 / "_model_config"
441 / "config.json"
442 )
443 assert json.loads(config_path.read_text(encoding="utf-8")) == {
444 "model_preset": "Balance"
445 }
446
447
448 def test_unified_preset_migration_preserves_global_and_scoped_configs(monkeypatch, tmp_path):
449 _prepare_a0_tree(monkeypatch, tmp_path)
450
451 from helpers import projects
452 from plugins._model_config.extensions.python.startup_migration._10_migrate_model_config import (
453 MigrateModelConfig,
454 )
455
456 global_dir = tmp_path / "usr" / "plugins" / "_model_config"
457 global_dir.mkdir(parents=True, exist_ok=True)
458 global_config = {
459 "chat_model": {"provider": "openrouter", "name": "legacy-global-chat"},
460 "utility_model": {"provider": "openrouter", "name": "legacy-global-utility"},
461 "embedding_model": {"provider": "huggingface", "name": "legacy-global-embedding"},
462 }
463 (global_dir / "config.json").write_text(json.dumps(global_config), encoding="utf-8")
464 (global_dir / "presets.yaml").write_text(
465 "- name: Existing\n chat:\n provider: openrouter\n name: existing-chat\n",
466 encoding="utf-8",
467 )
468
469 projects.create_project("demo", {"title": "Demo"})
470 project_config_path = (
471 tmp_path
472 / "usr"
473 / "projects"
474 / "demo"
475 / ".a0proj"
476 / "plugins"
477 / "_model_config"
478 / "config.json"
479 )
480 project_config_path.parent.mkdir(parents=True, exist_ok=True)
481 project_config_path.write_text(
482 json.dumps(
483 {
484 "chat_model": {"provider": "anthropic", "name": "project-chat"},
485 "utility_model": {"provider": "openrouter", "name": "project-utility"},
486 "embedding_model": {"provider": "openai", "name": "project-embedding"},
487 }
488 ),
489 encoding="utf-8",
490 )
491
492 migration = MigrateModelConfig(agent=None)
493 migration.execute()
494
495 presets = yaml.safe_load((global_dir / "presets.yaml").read_text(encoding="utf-8"))
496 assert [preset["name"] for preset in presets] == [
497 "Default",
498 "Existing",
499 "Project demo",
500 ]
501 assert presets[0]["chat"]["name"] == "legacy-global-chat"
502 assert presets[2]["embedding"]["name"] == "project-embedding"
503 assert json.loads((global_dir / "config.json").read_text(encoding="utf-8")) == {
504 "model_preset": "Default"
505 }
506 assert json.loads(project_config_path.read_text(encoding="utf-8")) == {
507 "model_preset": "Project demo"
508 }
509 assert Path(str(project_config_path) + ".pre-unified-presets.bak").exists()
510
511 before = (global_dir / "presets.yaml").read_text(encoding="utf-8")
512 migration.execute()
513 assert (global_dir / "presets.yaml").read_text(encoding="utf-8") == before
514
515
516 def test_unified_preset_migration_repairs_partial_legacy_default(monkeypatch, tmp_path):
517 _prepare_a0_tree(monkeypatch, tmp_path)
518
519 from plugins._model_config.extensions.python.startup_migration._10_migrate_model_config import (
520 MigrateModelConfig,
521 )
522
523 global_dir = tmp_path / "usr" / "plugins" / "_model_config"
524 global_dir.mkdir(parents=True, exist_ok=True)
525 (global_dir / "config.json").write_text(
526 json.dumps(
527 {
528 "chat_model": {
529 "provider": "anthropic",
530 "name": "legacy-chat",
531 }
532 }
533 ),
534 encoding="utf-8",
535 )
536
537 MigrateModelConfig(agent=None).execute()
538
539 presets = yaml.safe_load((global_dir / "presets.yaml").read_text(encoding="utf-8"))
540 default = presets[0]
541 assert default["name"] == "Default"
542 assert default["chat"]["name"] == "legacy-chat"
543 assert default["utility"]["name"] == "default-utility"
544 assert default["embedding"]["name"] == "default-embedding"
545
546
547 def test_unified_preset_migration_recovers_malformed_user_files(monkeypatch, tmp_path):
548 _prepare_a0_tree(monkeypatch, tmp_path)
549
550 from plugins._model_config.extensions.python.startup_migration._10_migrate_model_config import (
551 MigrateModelConfig,
552 )
553
554 global_dir = tmp_path / "usr" / "plugins" / "_model_config"
555 global_dir.mkdir(parents=True, exist_ok=True)
556 config_path = global_dir / "config.json"
557 presets_path = global_dir / "presets.yaml"
558 config_path.write_text("{broken", encoding="utf-8")
559 presets_path.write_text("not: [valid", encoding="utf-8")
560
561 MigrateModelConfig(agent=None).execute()
562
563 assert json.loads(config_path.read_text(encoding="utf-8")) == {
564 "model_preset": "Default"
565 }
566 presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
567 assert presets[0]["name"] == "Default"
568 assert Path(str(config_path) + ".pre-unified-presets.bak").exists()
569 assert Path(str(presets_path) + ".pre-unified-presets.bak").exists()
570
571
572 def test_new_instance_bootstraps_validated_remote_presets_once(monkeypatch, tmp_path):
573 _prepare_a0_tree(monkeypatch, tmp_path)
574
575 from plugins._model_config.extensions.python.startup_migration._10_migrate_model_config import (
576 MigrateModelConfig,
577 )
578 from plugins._model_config.extensions.python.startup_migration import (
579 _20_bootstrap_model_presets as bootstrap,
580 )
581
582 # The normal startup order runs legacy migration before initialization.
583 MigrateModelConfig(agent=None).execute()
584
585 remote_yaml = """
586 - name: Default
587 chat:
588 provider: openrouter
589 name: openai/gpt-test
590 api_key: must-not-persist
591 utility:
592 provider: openrouter
593 name: openai/gpt-test-mini
594 embedding:
595 provider: huggingface
596 name: sentence-transformers/test
597 - name: Efficiency
598 chat:
599 provider: openrouter
600 name: vendor/efficient
601 - name: Power
602 chat:
603 provider: openrouter
604 name: vendor/power
605 """.lstrip().encode()
606
607 class Response:
608 def __enter__(self):
609 return self
610
611 def __exit__(self, *_args):
612 return False
613
614 def read(self, _limit):
615 return remote_yaml
616
617 calls = []
618
619 def urlopen(request, timeout):
620 calls.append((request.full_url, timeout, request.headers.get("User-agent")))
621 return Response()
622
623 monkeypatch.setattr(bootstrap.urllib.request, "urlopen", urlopen)
624
625 result = bootstrap.BootstrapModelPresets(agent=None).execute()
626
627 presets_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
628 presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
629 assert result == "remote"
630 assert [preset["name"] for preset in presets] == ["Default", "Efficiency", "Power"]
631 assert "api_key" not in presets[0]["chat"]
632 assert calls == [
633 (
634 bootstrap.REMOTE_PRESETS_URL,
635 bootstrap.FETCH_TIMEOUT_SECONDS,
636 "AgentZero-Model-Preset-Bootstrap",
637 )
638 ]
639
640 monkeypatch.setattr(
641 bootstrap.urllib.request,
642 "urlopen",
643 lambda *_args, **_kwargs: (_ for _ in ()).throw(
644 AssertionError("bootstrap fetched more than once")
645 ),
646 )
647 assert bootstrap.BootstrapModelPresets(agent=None).execute() == "existing"
648
649
650 def test_remote_preset_failure_persists_local_fallback(monkeypatch, tmp_path):
651 _prepare_a0_tree(monkeypatch, tmp_path)
652
653 from plugins._model_config.extensions.python.startup_migration import (
654 _20_bootstrap_model_presets as bootstrap,
655 )
656
657 monkeypatch.setattr(
658 bootstrap.urllib.request,
659 "urlopen",
660 lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("offline")),
661 )
662
663 result = bootstrap.BootstrapModelPresets(agent=None).execute()
664
665 presets_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
666 presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
667 assert result == "fallback"
668 assert [preset["name"] for preset in presets] == ["Default", "Balance"]
669
670
671 def test_invalid_remote_preset_collection_uses_local_fallback(monkeypatch, tmp_path):
672 _prepare_a0_tree(monkeypatch, tmp_path)
673
674 from plugins._model_config.extensions.python.startup_migration import (
675 _20_bootstrap_model_presets as bootstrap,
676 )
677
678 class Response:
679 def __enter__(self):
680 return self
681
682 def __exit__(self, *_args):
683 return False
684
685 def read(self, _limit):
686 return b"- name: Default\n chat: invalid\n"
687
688 monkeypatch.setattr(
689 bootstrap.urllib.request,
690 "urlopen",
691 lambda *_args, **_kwargs: Response(),
692 )
693
694 result = bootstrap.BootstrapModelPresets(agent=None).execute()
695
696 presets_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
697 presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
698 assert result == "fallback"
699 assert [preset["name"] for preset in presets] == ["Default", "Balance"]
700
701
702 def test_remote_bootstrap_never_overwrites_existing_presets(monkeypatch, tmp_path):
703 _prepare_a0_tree(monkeypatch, tmp_path)
704
705 from plugins._model_config.extensions.python.startup_migration import (
706 _20_bootstrap_model_presets as bootstrap,
707 )
708
709 presets_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
710 presets_path.parent.mkdir(parents=True, exist_ok=True)
711 existing = "- name: Existing user preset\n"
712 presets_path.write_text(existing, encoding="utf-8")
713 monkeypatch.setattr(
714 bootstrap.urllib.request,
715 "urlopen",
716 lambda *_args, **_kwargs: (_ for _ in ()).throw(
717 AssertionError("existing instances must not fetch")
718 ),
719 )
720
721 result = bootstrap.BootstrapModelPresets(agent=None).execute()
722
723 assert result == "existing"
724 assert presets_path.read_text(encoding="utf-8") == existing
725
726
727 def test_obsolete_bootstrap_marker_does_not_block_missing_presets(monkeypatch, tmp_path):
728 _prepare_a0_tree(monkeypatch, tmp_path)
729
730 from plugins._model_config.extensions.python.startup_migration import (
731 _20_bootstrap_model_presets as bootstrap,
732 )
733 from plugins._model_config.helpers import model_config
734
735 marker = tmp_path / "usr" / "plugins" / "_model_config" / "preset_bootstrap.json"
736 marker.parent.mkdir(parents=True, exist_ok=True)
737 marker.write_text('{"source":"remote"}', encoding="utf-8")
738 monkeypatch.setattr(
739 bootstrap.BootstrapModelPresets,
740 "_download_presets",
741 lambda _self: model_config._fallback_presets(),
742 )
743
744 result = bootstrap.BootstrapModelPresets(agent=None).execute()
745
746 presets_path = marker.with_name("presets.yaml")
747 assert result == "remote"
748 assert presets_path.exists()
749 assert [
750 preset["name"]
751 for preset in yaml.safe_load(presets_path.read_text(encoding="utf-8"))
752 ] == ["Default", "Balance"]
753
754
755 def test_preset_rename_updates_scopes_and_unloaded_chats(monkeypatch, tmp_path):
756 _prepare_a0_tree(monkeypatch, tmp_path)
757
758 from plugins._model_config.api import model_presets
759
760 global_config = tmp_path / "usr" / "plugins" / "_model_config" / "config.json"
761 global_config.parent.mkdir(parents=True, exist_ok=True)
762 global_config.write_text(json.dumps({"model_preset": "Research"}), encoding="utf-8")
763
764 chat_path = tmp_path / "usr" / "chats" / "chat-1" / "chat.json"
765 chat_path.parent.mkdir(parents=True, exist_ok=True)
766 chat_path.write_text(
767 json.dumps(
768 {
769 "id": "chat-1",
770 "data": {"chat_model_override": {"preset_name": "Research"}},
771 }
772 ),
773 encoding="utf-8",
774 )
775 monkeypatch.setattr(model_presets.AgentContext, "all", lambda *_args: [])
776
777 model_presets._rename_preset_references(
778 [{"from": "Research", "to": "Deep Research"}]
779 )
780
781 assert json.loads(global_config.read_text(encoding="utf-8")) == {
782 "model_preset": "Deep Research"
783 }
784 chat = json.loads(chat_path.read_text(encoding="utf-8"))
785 assert chat["data"]["chat_model_override"] == {
786 "preset_name": "Deep Research"
787 }
788
789
790 def test_project_save_persists_only_selected_preset_reference(monkeypatch, tmp_path):
791 _prepare_a0_tree(monkeypatch, tmp_path)
792
793 from helpers import projects
794 from plugins._model_config.helpers import model_config
795
796 model_config.save_presets(
797 [
798 model_config.get_presets()[0],
799 {
800 "name": "Research",
801 "chat": {"provider": "anthropic", "name": "claude-research"},
802 "utility": {"provider": "openai", "name": "utility-research"},
803 }
804 ]
805 )
806
807 projects.create_project(
808 "demo",
809 {
810 "title": "Demo",
811 "llm": {
812 "selected_preset": {"scope": "global", "name": "Research"},
813 },
814 },
815 )
816
817 config_path = (
818 tmp_path
819 / "usr"
820 / "projects"
821 / "demo"
822 / ".a0proj"
823 / "plugins"
824 / "_model_config"
825 / "config.json"
826 )
827 config = json.loads(config_path.read_text(encoding="utf-8"))
828 assert config == {"model_preset": "Research"}
829
830 project_json = (
831 tmp_path / "usr" / "projects" / "demo" / ".a0proj" / "project.json"
832 ).read_text(encoding="utf-8")
833 assert "llm" not in project_json
834 assert "_model_config" not in project_json
835
836
837 def test_project_save_does_not_freeze_inherited_global_model_config(
838 monkeypatch,
839 tmp_path,
840 ):
841 _prepare_a0_tree(monkeypatch, tmp_path)
842
843 from helpers import plugins, projects
844
845 projects.create_project("demo", {"title": "Demo"})
846 config_path = (
847 tmp_path
848 / "usr"
849 / "projects"
850 / "demo"
851 / ".a0proj"
852 / "plugins"
853 / "_model_config"
854 / "config.json"
855 )
856
857 project_data = projects.load_edit_project_data("demo")
858 assert project_data["llm"]["has_project_config"] is False
859
860 projects.update_project("demo", project_data)
861
862 assert not config_path.exists()
863
864 plugins.save_plugin_config("_model_config", "", "", {"model_preset": "Balance"})
865
866 reloaded_data = projects.load_edit_project_data("demo")
867 assert reloaded_data["llm"]["has_project_config"] is False
868 assert reloaded_data["llm"]["selected_preset"]["name"] == "Balance"
869
870
871 def test_project_save_updates_existing_scoped_preset_selection(monkeypatch, tmp_path):
872 _prepare_a0_tree(monkeypatch, tmp_path)
873
874 from helpers import plugins, projects
875
876 projects.create_project("demo", {"title": "Demo"})
877 plugins.save_plugin_config(
878 "_model_config",
879 "demo",
880 "",
881 {"model_preset": "Default"},
882 )
883
884 project_data = projects.load_edit_project_data("demo")
885 assert project_data["llm"]["has_project_config"] is True
886 project_data["llm"]["selected_preset"]["name"] = "Balance"
887
888 projects.update_project("demo", project_data)
889
890 config_path = (
891 tmp_path
892 / "usr"
893 / "projects"
894 / "demo"
895 / ".a0proj"
896 / "plugins"
897 / "_model_config"
898 / "config.json"
899 )
900 config = json.loads(config_path.read_text(encoding="utf-8"))
901 assert config == {"model_preset": "Balance"}
902
903
904 def test_project_extended_data_supports_multiple_plugin_sections(
905 monkeypatch,
906 tmp_path,
907 ):
908 _prepare_a0_tree(monkeypatch, tmp_path)
909 _add_project_extra_plugin(tmp_path)
910
911 from helpers import projects
912
913 projects.create_project(
914 "demo",
915 {
916 "title": "Demo",
917 "git_token": "secret-token",
918 "extra": {"enabled": False, "note": "created"},
919 },
920 )
921
922 saved_path = (
923 tmp_path
924 / "usr"
925 / "projects"
926 / "demo"
927 / ".a0proj"
928 / "extra_saved.json"
929 )
930 assert json.loads(saved_path.read_text(encoding="utf-8")) == {
931 "project": "demo",
932 "extra": {"enabled": False, "note": "created"},
933 }
934
935 project_data = projects.load_edit_project_data("demo")
936 assert project_data["llm"]["has_project_config"] is False
937 assert project_data["extra"] == {"loaded_for": "demo", "enabled": True}
938
939 project_data["extra"] = {"enabled": True, "note": "updated"}
940 projects.update_project("demo", project_data)
941
942 assert json.loads(saved_path.read_text(encoding="utf-8")) == {
943 "project": "demo",
944 "extra": {"enabled": True, "note": "updated"},
945 }
946
947
948 def test_project_extended_data_cannot_overwrite_core_fields(monkeypatch, tmp_path):
949 _prepare_a0_tree(monkeypatch, tmp_path)
950 _add_project_conflict_plugin(tmp_path)
951
952 from helpers import projects
953
954 projects.create_project("demo", {"title": "Demo"})
955
956 with pytest.raises(
957 ValueError,
958 match="Project extension data cannot overwrite core project fields: title",
959 ):
960 projects.load_edit_project_data("demo")
961
962
963 def test_preset_application_preserves_tuning_but_replaces_kwargs(monkeypatch, tmp_path):
964 _prepare_a0_tree(monkeypatch, tmp_path)
965
966 from plugins._model_config.helpers import model_config
967
968 base_config = {
969 "allow_chat_override": True,
970 "chat_model": {
971 "provider": "openrouter",
972 "name": "configured-chat",
973 "ctx_length": 200000,
974 "ctx_history": 0.5,
975 "kwargs": {"temperature": 0.2, "routing": {"order": ["a", "b"]}},
976 },
977 "utility_model": {
978 "provider": "openrouter",
979 "name": "configured-utility",
980 "ctx_length": 200000,
981 "ctx_input": 0.4,
982 "kwargs": {"temperature": 0.1, "routing": {"order": ["fast"]}},
983 },
984 "embedding_model": {
985 "provider": "huggingface",
986 "name": "configured-embedding",
987 "kwargs": {"device": "cpu", "batch_size": 16},
988 },
989 }
990 preset = {
991 "name": "Research",
992 "chat": {
993 "provider": "anthropic",
994 "name": "claude-research",
995 "kwargs": {"routing": {"priority": "quality"}},
996 },
997 "utility": {
998 "provider": "openrouter",
999 "name": "utility-research",
1000 "kwargs": {"routing": {"timeout": 30}},
1001 },
1002 "embedding": {
1003 "provider": "openai",
1004 "name": "text-embedding-3-large",
1005 },
1006 }
1007
1008 config = model_config.build_config_from_preset(preset, base_config)
1009
1010 assert config["chat_model"]["name"] == "claude-research"
1011 assert config["chat_model"]["ctx_length"] == 200000
1012 assert config["chat_model"]["kwargs"] == {"routing": {"priority": "quality"}}
1013 assert config["utility_model"]["name"] == "utility-research"
1014 assert config["utility_model"]["ctx_length"] == 200000
1015 assert config["utility_model"]["ctx_input"] == 0.4
1016 assert config["utility_model"]["kwargs"] == {"routing": {"timeout": 30}}
1017 assert config["embedding_model"]["name"] == "text-embedding-3-large"
1018 assert config["embedding_model"]["kwargs"] == {}
1019
1020
1021 def test_preset_application_clears_stale_kwargs_when_preset_omits_them(
1022 monkeypatch,
1023 tmp_path,
1024 ):
1025 _prepare_a0_tree(monkeypatch, tmp_path)
1026
1027 from plugins._model_config.helpers import model_config
1028
1029 base_config = {
1030 "chat_model": {
1031 "provider": "openrouter",
1032 "name": "openai/gpt-5.4",
1033 "ctx_length": 200000,
1034 "kwargs": {"temperature": 0, "extra_headers": {"x-old": "true"}},
1035 },
1036 "utility_model": {
1037 "provider": "openrouter",
1038 "name": "openai/gpt-5.4-mini",
1039 "ctx_length": 128000,
1040 "kwargs": {"temperature": 0},
1041 },
1042 }
1043 preset = {
1044 "name": "Codex",
1045 "chat": {
1046 "provider": "codex_oauth",
1047 "name": "gpt-5.1-codex",
1048 },
1049 "utility": {
1050 "provider": "codex_oauth",
1051 "name": "gpt-5.1-codex-mini",
1052 },
1053 }
1054
1055 config = model_config.build_config_from_preset(preset, base_config)
1056
1057 assert config["chat_model"]["name"] == "gpt-5.1-codex"
1058 assert config["chat_model"]["ctx_length"] == 200000
1059 assert config["chat_model"]["kwargs"] == {}
1060 assert config["utility_model"]["name"] == "gpt-5.1-codex-mini"
1061 assert config["utility_model"]["ctx_length"] == 128000
1062 assert config["utility_model"]["kwargs"] == {}
1063
1064
1065 def test_preset_application_inherits_optional_slots(monkeypatch, tmp_path):
1066 _prepare_a0_tree(monkeypatch, tmp_path)
1067
1068 from plugins._model_config.helpers import model_config
1069
1070 base_config = {
1071 "chat_model": {"provider": "openrouter", "name": "configured-chat"},
1072 "utility_model": {
1073 "provider": "openrouter",
1074 "name": "configured-utility",
1075 "ctx_length": 200000,
1076 },
1077 "embedding_model": {
1078 "provider": "huggingface",
1079 "name": "configured-embedding",
1080 },
1081 }
1082 preset = {
1083 "name": "Chat Only",
1084 "chat": {"provider": "anthropic", "name": "claude-research"},
1085 "utility": {"ctx_length": 128000},
1086 }
1087
1088 config = model_config.build_config_from_preset(preset, base_config)
1089
1090 assert config["chat_model"]["name"] == "claude-research"
1091 assert config["utility_model"] == base_config["utility_model"]
1092 assert config["embedding_model"] == base_config["embedding_model"]
1093
1094
1095 def test_preset_vision_slot_is_optional_and_never_inherited(monkeypatch, tmp_path):
1096 _prepare_a0_tree(monkeypatch, tmp_path)
1097
1098 from plugins._model_config.helpers import model_config
1099
1100 base_config = {
1101 "chat_model": {"provider": "openrouter", "name": "main", "vision": False},
1102 "vision_model": {
1103 "provider": "openrouter",
1104 "name": "default-vision",
1105 "max_embeds": 2,
1106 },
1107 }
1108
1109 without_sidecar = model_config.build_config_from_preset(
1110 {"name": "Text only", "chat": {"provider": "openrouter", "name": "text"}},
1111 base_config,
1112 )
1113 with_sidecar = model_config.build_config_from_preset(
1114 {
1115 "name": "Visual",
1116 "chat": {"provider": "openrouter", "name": "text"},
1117 "vision": {
1118 "provider": "anthropic",
1119 "name": "visual",
1120 "max_embeds": 5,
1121 },
1122 },
1123 base_config,
1124 )
1125
1126 assert without_sidecar["vision_model"] == {}
1127 assert with_sidecar["vision_model"]["provider"] == "anthropic"
1128 assert with_sidecar["vision_model"]["name"] == "visual"
1129 assert with_sidecar["vision_model"]["max_embeds"] == 5
1130 assert "default-vision" not in str(with_sidecar["vision_model"])
1131
1132
1133 def test_vision_call_limits_are_preset_owned_and_default_clean(monkeypatch, tmp_path):
1134 _prepare_a0_tree(monkeypatch, tmp_path)
1135
1136 from plugins._model_config.helpers import model_config
1137
1138 cleaned = model_config.clean_presets_for_file(
1139 [
1140 {
1141 "name": "Default limits",
1142 "vision": {
1143 "provider": "openrouter",
1144 "name": "vision-default",
1145 "timeout": 300,
1146 "max_tokens": 2000,
1147 },
1148 },
1149 {
1150 "name": "Tuned limits",
1151 "vision": {
1152 "provider": "openrouter",
1153 "name": "vision-tuned",
1154 "timeout": 45,
1155 "max_tokens": 512,
1156 },
1157 },
1158 ]
1159 )
1160
1161 assert cleaned[0]["vision"] == {
1162 "provider": "openrouter",
1163 "name": "vision-default",
1164 }
1165 assert cleaned[1]["vision"]["timeout"] == 45
1166 assert cleaned[1]["vision"]["max_tokens"] == 512
1167
1168
1169 def test_vision_model_build_applies_only_its_preset_call_limits(monkeypatch):
1170 from plugins._model_config.helpers import model_config
1171
1172 calls = []
1173
1174 def fake_get_chat_model(provider, name, **kwargs):
1175 calls.append((provider, name, kwargs))
1176 return kwargs
1177
1178 monkeypatch.setattr(model_config.models, "get_chat_model", fake_get_chat_model)
1179
1180 cases = [
1181 (
1182 {"provider": "openrouter", "name": "vision-default"},
1183 {"timeout": 300, "max_tokens": 2000},
1184 ),
1185 (
1186 {
1187 "provider": "openrouter",
1188 "name": "vision-legacy-kwargs",
1189 "kwargs": {"timeout": 90, "max_tokens": 1024},
1190 },
1191 {"timeout": 90, "max_tokens": 1024},
1192 ),
1193 (
1194 {
1195 "provider": "openrouter",
1196 "name": "vision-tuned",
1197 "timeout": "45",
1198 "max_tokens": "512",
1199 "kwargs": {
1200 "timeout": 90,
1201 "max_tokens": 1024,
1202 "temperature": 0.1,
1203 },
1204 },
1205 {"timeout": 45, "max_tokens": 512},
1206 ),
1207 ]
1208
1209 for config, expected in cases:
1210 monkeypatch.setattr(
1211 model_config,
1212 "get_vision_model_config",
1213 lambda _agent=None, config=config: config,
1214 )
1215 built = model_config.build_vision_model()
1216 assert built["timeout"] == expected["timeout"]
1217 assert built["max_tokens"] == expected["max_tokens"]
1218
1219 assert calls[-1][2]["temperature"] == 0.1
1220
1221 monkeypatch.setattr(
1222 model_config,
1223 "get_chat_model_config",
1224 lambda _agent=None: {"provider": "openrouter", "name": "main"},
1225 )
1226 main = model_config.build_chat_model()
1227 assert "timeout" not in main
1228 assert "max_tokens" not in main
1229
1230
1231 def test_legacy_utility_preset_defaults_preserve_tuning_but_clear_kwargs(
1232 monkeypatch,
1233 tmp_path,
1234 ):
1235 _prepare_a0_tree(monkeypatch, tmp_path)
1236
1237 from plugins._model_config.helpers import model_config
1238
1239 base_config = {
1240 "utility_model": {
1241 "provider": "openrouter",
1242 "name": "configured-utility",
1243 "api_base": "https://custom.example/v1",
1244 "ctx_length": 200000,
1245 "ctx_input": 0.4,
1246 "rl_requests": 12,
1247 "rl_input": 34000,
1248 "rl_output": 56000,
1249 "kwargs": {"temperature": 0.1},
1250 },
1251 }
1252 preset = {
1253 "name": "Legacy Saved Preset",
1254 "utility": {
1255 "provider": "openrouter",
1256 "name": "preset-utility",
1257 "api_key": "",
1258 "api_base": "",
1259 "ctx_length": 128000,
1260 "ctx_input": 0.7,
1261 "rl_requests": 0,
1262 "rl_input": 0,
1263 "rl_output": 0,
1264 "kwargs": {},
1265 },
1266 }
1267
1268 config = model_config.build_config_from_preset(
1269 preset,
1270 base_config,
1271 strip_api_key=False,
1272 )
1273
1274 utility = config["utility_model"]
1275 assert utility["name"] == "preset-utility"
1276 assert utility["api_base"] == ""
1277 assert "api_key" not in utility
1278 assert utility["ctx_length"] == 200000
1279 assert utility["ctx_input"] == 0.4
1280 assert utility["rl_requests"] == 12
1281 assert utility["rl_input"] == 34000
1282 assert utility["rl_output"] == 56000
1283 assert utility["kwargs"] == {}
1284
1285
1286 def test_preset_override_preserves_default_preset_utility_context(monkeypatch, tmp_path):
1287 _prepare_a0_tree(monkeypatch, tmp_path)
1288
1289 from plugins._model_config.helpers import model_config
1290
1291 base_config = {
1292 "allow_chat_override": True,
1293 "chat_model": {"provider": "openrouter", "name": "configured-chat"},
1294 "utility_model": {
1295 "provider": "openrouter",
1296 "name": "configured-utility",
1297 "ctx_length": 200000,
1298 "ctx_input": 0.4,
1299 },
1300 }
1301 preset = {
1302 "name": "Fast",
1303 "chat": {"provider": "openrouter", "name": "fast-chat"},
1304 "utility": {"provider": "openrouter", "name": "fast-utility"},
1305 }
1306
1307 class FakeContext:
1308 def get_data(self, key):
1309 return {"preset_name": "Fast"} if key == "chat_model_override" else None
1310
1311 class FakeAgent:
1312 context = FakeContext()
1313
1314 monkeypatch.setattr(model_config, "get_config", lambda *args, **kwargs: base_config)
1315 monkeypatch.setattr(
1316 model_config,
1317 "get_preset_by_name",
1318 lambda name, **kwargs: preset if name == "Fast" else None,
1319 )
1320 default_preset = model_config.config_to_preset(base_config, "Default")
1321 monkeypatch.setattr(
1322 model_config,
1323 "resolve_preset",
1324 lambda name, **kwargs: preset if name == "Fast" else default_preset if name == "Default" else None,
1325 )
1326
1327 utility = model_config.get_utility_model_config(FakeAgent())
1328
1329 assert utility["name"] == "fast-utility"
1330 assert utility["ctx_length"] == 200000
1331 assert utility["ctx_input"] == 0.4
1332
1333
1334 def test_missing_scoped_preset_falls_back_to_default(monkeypatch, tmp_path):
1335 _prepare_a0_tree(monkeypatch, tmp_path)
1336
1337 from helpers import plugins, projects
1338 from plugins._model_config.helpers import model_config
1339
1340 projects.create_project("demo", {"title": "Demo"})
1341 plugins.save_plugin_config(
1342 "_model_config",
1343 "demo",
1344 "",
1345 {"model_preset": "Deleted"},
1346 )
1347
1348 assert model_config.get_configured_preset_name(project_name="demo") == "Default"
1349 resolved = model_config.get_config(project_name="demo")
1350 assert resolved["model_preset"] == "Default"
1351 assert resolved["chat_model"]["name"] == "default-chat"