7
from pathlib import Path
8
9
import pytest
10
+import yaml
11
from flask import Flask
12
13
80
"name: _model_config\nper_project_config: true\nper_agent_config: true\n",
81
encoding="utf-8",
82
)
82
- (plugin_dir / "default_presets.yaml").write_text(
83
+ fallback_path = plugin_dir / "mode_presets_fallback.yaml"
84
+ fallback_path.write_text(
85
"""
84
-- name: Default Balance
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
"""
96
-allow_chat_override: true
97
-chat_model:
98
- provider: openrouter
99
- name: configured-chat
100
-utility_model:
101
- provider: openrouter
102
- name: configured-utility
103
-embedding_model:
104
- provider: huggingface
105
- name: configured-embedding
105
+model_preset: Default
106
""".lstrip(),
107
encoding="utf-8",
108
)
242
_clear_runtime_caches()
243
244
245
-def test_global_presets_keep_legacy_default_and_save_behavior(monkeypatch, tmp_path):
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 Balance"
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",
262
)
263
264
presets = model_config.get_presets()
264
- assert presets == [
265
- {"name": "Global One", "chat": {"provider": "openai", "name": "gpt-test"}}
266
- ]
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
271
- model_config.save_presets([])
272
- assert model_config.get_presets() == []
274
+ with pytest.raises(ValueError, match="cannot be deleted or renamed"):
275
+ model_config.save_presets([])
276
274
- assert model_config.reset_presets()[0]["name"] == "Default Balance"
277
+ assert model_config.reset_presets()[0]["name"] == "Default"
278
279
277
-def test_project_presets_are_separate_and_resolve_by_scope(monkeypatch, tmp_path):
280
+def test_project_scope_selects_global_presets_only(monkeypatch, tmp_path):
281
_prepare_a0_tree(monkeypatch, tmp_path)
282
280
- from helpers import projects
283
+ from helpers import projects, plugins
284
from plugins._model_config.helpers import model_config
285
286
projects.create_project("demo", {"title": "Demo"})
284
- model_config.save_presets(
285
- [{"name": "Shared", "chat": {"provider": "global", "name": "chat"}}]
286
- )
287
- model_config.save_presets(
288
- [{"name": "Shared", "chat": {"provider": "project", "name": "chat"}}],
289
- project_name="demo",
290
- )
287
+ plugins.save_plugin_config("_model_config", "demo", "", {"model_preset": "Balance"})
288
292
- assert model_config.get_presets()[0]["chat"]["provider"] == "global"
293
- assert model_config.get_project_presets("demo")[0]["chat"]["provider"] == "project"
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
- combined = model_config.get_combined_presets("demo")
296
- assert [(item["scope"], item["project_name"], item["name"]) for item in combined] == [
297
- ("global", "", "Shared"),
298
- ("project", "demo", "Shared"),
299
- ]
300
- assert model_config.resolve_preset("Shared", scope="global")["chat"]["provider"] == "global"
301
- assert (
302
- model_config.resolve_preset("Shared", scope="project", project_name="demo")["chat"]["provider"]
303
- == "project"
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
307
-def test_bundled_utility_presets_inherit_advanced_settings():
308
- import yaml
339
310
- presets_path = PROJECT_ROOT / "plugins" / "_model_config" / "default_presets.yaml"
311
- presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
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
320
-async def test_model_presets_api_returns_global_or_combined_by_project(monkeypatch, tmp_path):
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
389
from plugins._model_config.helpers import model_config
390
391
projects.create_project("demo", {"title": "Demo"})
328
- model_config.save_presets(
329
- [{"name": "Global", "chat": {"provider": "global", "name": "chat"}}]
330
- )
331
- model_config.save_presets(
332
- [{"name": "Project", "chat": {"provider": "project", "name": "chat"}}],
333
- project_name="demo",
334
- )
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)
338
- assert global_response["presets"] == [
339
- {"name": "Global", "chat": {"provider": "global", "name": "chat"}}
398
+ assert [preset["name"] for preset in global_response["presets"]] == [
399
+ "Default",
400
+ "Balance",
401
+ "Global",
402
]
341
- assert "scope" not in global_response["presets"][0]
403
404
project_response = await handler.process({"action": "get", "project_name": "demo"}, None)
344
- assert [(p["scope"], p["name"]) for p in project_response["presets"]] == [
345
- ("global", "Global"),
346
- ("project", "Project"),
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
350
-def test_project_save_copies_selected_preset_to_scoped_model_config(monkeypatch, tmp_path):
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
795
796
model_config.save_presets(
797
[
798
+ model_config.get_presets()[0],
799
{
800
"name": "Research",
801
"chat": {"provider": "anthropic", "name": "claude-research"},
825
/ "config.json"
826
)
827
config = json.loads(config_path.read_text(encoding="utf-8"))
387
- assert config["chat_model"]["name"] == "claude-research"
388
- assert config["utility_model"]["name"] == "utility-research"
389
- assert config["embedding_model"]["name"] == "configured-embedding"
828
+ assert config == {"model_preset": "Research"}
829
830
project_json = (
831
tmp_path / "usr" / "projects" / "demo" / ".a0proj" / "project.json"
855
)
856
857
project_data = projects.load_edit_project_data("demo")
419
- assert project_data["llm"]["config_scope"] == "inherited"
858
assert project_data["llm"]["has_project_config"] is False
421
- project_data["llm"].pop("config_scope")
422
- project_data["llm"].pop("has_project_config")
859
860
projects.update_project("demo", project_data)
861
862
assert not config_path.exists()
863
428
- plugins.save_plugin_config(
429
- "_model_config",
430
- "",
431
- "",
432
- {
433
- "chat_model": {"provider": "openrouter", "name": "new-global-chat"},
434
- "utility_model": {"provider": "openrouter", "name": "new-global-utility"},
435
- "embedding_model": {
436
- "provider": "huggingface",
437
- "name": "new-global-embedding",
438
- },
439
- },
440
- )
864
+ plugins.save_plugin_config("_model_config", "", "", {"model_preset": "Balance"})
865
866
reloaded_data = projects.load_edit_project_data("demo")
443
- assert reloaded_data["llm"]["config_scope"] == "inherited"
444
- assert reloaded_data["llm"]["config"]["chat_model"]["name"] == "new-global-chat"
867
+ assert reloaded_data["llm"]["has_project_config"] is False
868
+ assert reloaded_data["llm"]["selected_preset"]["name"] == "Balance"
869
870
447
-def test_project_save_updates_existing_scoped_model_config(monkeypatch, tmp_path):
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
878
"_model_config",
879
"demo",
880
"",
457
- {
458
- "chat_model": {"provider": "openrouter", "name": "project-chat"},
459
- "utility_model": {"provider": "openrouter", "name": "project-utility"},
460
- "embedding_model": {"provider": "huggingface", "name": "project-embedding"},
461
- },
881
+ {"model_preset": "Default"},
882
)
883
884
project_data = projects.load_edit_project_data("demo")
465
- assert project_data["llm"]["config_scope"] == "project"
466
- project_data["llm"]["config"]["chat_model"]["name"] = "project-chat-updated"
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
898
/ "config.json"
899
)
900
config = json.loads(config_path.read_text(encoding="utf-8"))
481
- assert config["chat_model"]["name"] == "project-chat-updated"
901
+ assert config == {"model_preset": "Balance"}
902
903
904
def test_project_extended_data_supports_multiple_plugin_sections(
933
}
934
935
project_data = projects.load_edit_project_data("demo")
516
- assert project_data["llm"]["config_scope"] == "inherited"
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"}
1147
assert utility["kwargs"] == {}
1148
1149
730
-def test_preset_override_preserves_configured_utility_context(monkeypatch, tmp_path):
1150
+def test_preset_override_preserves_default_preset_utility_context(monkeypatch, tmp_path):
1151
_prepare_a0_tree(monkeypatch, tmp_path)
1152
1153
from plugins._model_config.helpers import model_config
1181
"get_preset_by_name",
1182
lambda name, **kwargs: preset if name == "Fast" else None,
1183
)
1184
+ default_preset = model_config.config_to_preset(base_config, "Default")
1185
+ monkeypatch.setattr(
1186
+ model_config,
1187
+ "resolve_preset",
1188
+ lambda name, **kwargs: preset if name == "Fast" else default_preset if name == "Default" else None,
1189
+ )
1190
1191
utility = model_config.get_utility_model_config(FakeAgent())
1192
1195
assert utility["ctx_input"] == 0.4
1196
1197
772
-def test_project_save_disambiguates_same_name_project_preset(monkeypatch, tmp_path):
1198
+def test_missing_scoped_preset_falls_back_to_default(monkeypatch, tmp_path):
1199
_prepare_a0_tree(monkeypatch, tmp_path)
1200
775
- from helpers import projects
1201
+ from helpers import plugins, projects
1202
from plugins._model_config.helpers import model_config
1203
778
- model_config.save_presets(
779
- [{"name": "Shared", "chat": {"provider": "global", "name": "chat"}}]
780
- )
781
- projects.create_project(
1204
+ projects.create_project("demo", {"title": "Demo"})
1205
+ plugins.save_plugin_config(
1206
+ "_model_config",
1207
"demo",
783
- {
784
- "title": "Demo",
785
- "llm": {
786
- "selected_preset": {
787
- "scope": "project",
788
- "project_name": "demo",
789
- "name": "Shared",
790
- },
791
- "project_presets": [
792
- {"name": "Shared", "chat": {"provider": "project", "name": "chat"}}
793
- ],
794
- },
795
- },
1208
+ "",
1209
+ {"model_preset": "Deleted"},
1210
)
1211
798
- config = json.loads(
799
- (
800
- tmp_path
801
- / "usr"
802
- / "projects"
803
- / "demo"
804
- / ".a0proj"
805
- / "plugins"
806
- / "_model_config"
807
- / "config.json"
808
- ).read_text(encoding="utf-8")
809
- )
810
- assert config["chat_model"]["provider"] == "project"
811
-
812
- presets_yaml = (
813
- tmp_path
814
- / "usr"
815
- / "projects"
816
- / "demo"
817
- / ".a0proj"
818
- / "plugins"
819
- / "_model_config"
820
- / "presets.yaml"
821
- ).read_text(encoding="utf-8")
822
- assert "scope:" not in presets_yaml
823
- assert "project_name:" not in presets_yaml
1212
+ assert model_config.get_configured_preset_name(project_name="demo") == "Default"
1213
+ resolved = model_config.get_config(project_name="demo")
1214
+ assert resolved["model_preset"] == "Default"
1215
+ assert resolved["chat_model"]["name"] == "default-chat"