From 1cb6f62b07d1993868b8d56ab20ba34758cad79a Mon Sep 17 00:00:00 2001 From: Sam Schillace Date: Thu, 6 Aug 2026 11:22:45 -0700 Subject: [PATCH 1/3] fix(spawn): deterministic provider glob semantics and canonical async model resolution --- amplifier_foundation/spawn_utils.py | 128 ++++++++++++++-------- tests/test_spawn_utils.py | 160 ++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 42 deletions(-) diff --git a/amplifier_foundation/spawn_utils.py b/amplifier_foundation/spawn_utils.py index d7b966a..d154500 100644 --- a/amplifier_foundation/spawn_utils.py +++ b/amplifier_foundation/spawn_utils.py @@ -451,6 +451,37 @@ def _build_provider_lookup( return lookup +def _select_provider_preference( + providers: list[dict[str, Any]], + preferences: list[ProviderPreference], +) -> tuple[ProviderPreference, int] | None: + """Select the first available provider preference. + + Exact-name lookup retains its existing aliases and precedence. If an exact + lookup misses, the preference is matched as a case-sensitive glob against + each provider's module, short module name, and optional instance id in mount + order. + """ + lookup = _build_provider_lookup(providers) + + for pref in preferences: + target_idx = lookup.get(pref.provider) + if target_idx is not None: + return pref, target_idx + + for i, provider in enumerate(providers): + module_id = provider.get("module", "") + names = [module_id, module_id.replace("provider-", "")] + instance_id = provider.get("id") + if instance_id: + names.append(instance_id) + + if any(fnmatch.fnmatchcase(name, pref.provider) for name in names): + return pref, i + + return None + + def apply_provider_preferences( mount_plan: dict[str, Any], preferences: list[ProviderPreference], @@ -484,16 +515,12 @@ def apply_provider_preferences( logger.warning("Provider preferences specified but no providers in mount plan") return mount_plan - # Build lookup for efficient matching - lookup = _build_provider_lookup(providers) - - # Find first matching preference - for pref in preferences: - if pref.provider in lookup: - target_idx = lookup[pref.provider] - return _apply_single_override( - mount_plan, providers, target_idx, pref.model, pref.config - ) + selection = _select_provider_preference(providers, preferences) + if selection: + pref, target_idx = selection + return _apply_single_override( + mount_plan, providers, target_idx, pref.model, pref.config + ) # No preferences matched logger.warning( @@ -591,47 +618,64 @@ async def apply_provider_preferences_with_resolution( logger.warning("Provider preferences specified but no providers in mount plan") return mount_plan - # Build lookup for efficient matching + selection = _select_provider_preference(providers, preferences) + if selection: + pref, target_idx = selection + + # Iterate preferences in order: for each preference, try exact-name lookup + # first, then fall back to a mount-order, case-sensitive glob match over + # canonical module, short name, and instance id. If the model is a glob + # pattern, resolve it against the selected mount's canonical module and + # advance to the next preference if resolution fails (do NOT apply raw, + # unresolved glob strings into the mount plan). lookup = _build_provider_lookup(providers) - # Find first matching preference whose model actually resolves, and - # apply it. A preference whose provider is present but whose glob - # pattern fails to resolve (no matching models) is NOT applied with - # the raw, unresolved pattern -- that would send a literal glob string - # to the provider's API. Instead we advance to the next preference in - # the ordered list, mirroring resolve_model_role()'s `continue` - # behavior in the sibling routing-matrix resolver. for pref in preferences: - if pref.provider in lookup: - target_idx = lookup[pref.provider] - - # Resolve model pattern if it's a glob - resolved_model = pref.model - if is_glob_pattern(pref.model): - result = await resolve_model_pattern( - pref.model, pref.provider, coordinator + # Exact-name match (fast path) + target_idx = lookup.get(pref.provider) + if target_idx is None: + # Mount-order glob match over module, short name, and id + for i, provider in enumerate(providers): + module_id = provider.get("module", "") + names = [module_id, module_id.replace("provider-", "")] + instance_id = provider.get("id") + if instance_id: + names.append(instance_id) + if any(fnmatch.fnmatchcase(name, pref.provider) for name in names): + target_idx = i + break + + if target_idx is None: + # Preference's provider not present in this mount plan, try next + continue + + # Resolve model pattern if it's a glob, using the selected mount's + # canonical module id (e.g., 'provider-openai'), not the flexible alias + # the preference might have used to select the provider. + resolved_model = pref.model + if is_glob_pattern(pref.model): + canonical_module = providers[target_idx].get("module", "") + result = await resolve_model_pattern(pref.model, canonical_module, coordinator) + if result.resolved_model is None: + logger.warning( + "Preference for provider '%s' failed to resolve model pattern '%s' - trying next preference", + pref.provider, + pref.model, ) - if result.resolved_model is None: - logger.warning( - "Preference for provider '%s' failed to resolve model " - "pattern '%s' - trying next preference", - pref.provider, - pref.model, - ) - continue - resolved_model = result.resolved_model - - return _apply_single_override( - mount_plan, providers, target_idx, resolved_model, pref.config - ) + continue + resolved_model = result.resolved_model + + return _apply_single_override( + mount_plan, providers, target_idx, resolved_model, pref.config + ) # No preferences matched -- either no preference's provider was present - # in the mount plan, or every candidate's model pattern failed to - # resolve. Either way, leave the mount plan unmodified rather than - # writing an unresolved pattern string into it. + # in the mount plan, or every candidate's model pattern failed to resolve. + # Leave the mount plan unmodified rather than writing an unresolved pattern. logger.warning( "No preferred providers found in mount plan. Preferences: %s, Available: %s", [p.provider for p in preferences], list({p.get("module", "?") for p in providers}), ) return mount_plan + diff --git a/tests/test_spawn_utils.py b/tests/test_spawn_utils.py index 3dcea8b..6a4d95b 100644 --- a/tests/test_spawn_utils.py +++ b/tests/test_spawn_utils.py @@ -197,6 +197,133 @@ def test_mount_plan_not_mutated(self) -> None: assert result["providers"][0]["config"]["priority"] == 0 assert result["providers"][0]["config"]["default_model"] == "claude-haiku-3" + @pytest.mark.parametrize( + ("provider_pattern", "expected_index"), + [ + ("*", 0), + ("provider-anth*", 0), + ("anthrop?c", 0), + ("team-[ab]", 0), + ], + ) + def test_provider_glob_matches_module_short_name_or_id( + self, + provider_pattern: str, + expected_index: int, + ) -> None: + """Provider globs match all contracted provider names.""" + mount_plan = { + "providers": [ + { + "module": "provider-anthropic", + "id": "team-a", + "config": {"priority": 10}, + }, + { + "module": "provider-openai", + "id": "team-b", + "config": {"priority": 20}, + }, + ] + } + + result = apply_provider_preferences( + mount_plan, + [ProviderPreference(provider=provider_pattern, model="selected-model")], + ) + + assert result["providers"][expected_index]["config"]["priority"] == 0 + assert ( + result["providers"][expected_index]["config"]["default_model"] + == "selected-model" + ) + + def test_provider_glob_uses_first_provider_in_mount_order(self) -> None: + """A glob matching multiple providers deterministically selects the first.""" + mount_plan = { + "providers": [ + {"module": "provider-openai-first", "config": {"priority": 10}}, + {"module": "provider-openai-second", "config": {"priority": 20}}, + ] + } + + result = apply_provider_preferences( + mount_plan, + [ProviderPreference(provider="openai-*", model="gpt-selected")], + ) + + assert result["providers"][0]["config"]["priority"] == 0 + assert result["providers"][0]["config"]["default_model"] == "gpt-selected" + assert result["providers"][1]["config"] == {"priority": 20} + + def test_exact_lookup_precedence_is_preserved(self) -> None: + """Exact duplicate aliases retain the existing last-entry lookup behavior.""" + mount_plan = { + "providers": [ + {"module": "provider-openai", "config": {"priority": 10}}, + {"module": "provider-openai", "config": {"priority": 20}}, + ] + } + + result = apply_provider_preferences( + mount_plan, + [ProviderPreference(provider="openai", model="gpt-selected")], + ) + + assert result["providers"][0]["config"] == {"priority": 10} + assert result["providers"][1]["config"]["priority"] == 0 + assert result["providers"][1]["config"]["default_model"] == "gpt-selected" + + def test_provider_glob_applies_model_and_config_without_warning( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """A glob match applies the full override and suppresses no-match warnings.""" + mount_plan = { + "providers": [ + { + "module": "provider-openai", + "config": {"priority": 10, "reasoning_effort": "low"}, + } + ] + } + + result = apply_provider_preferences( + mount_plan, + [ + ProviderPreference( + provider="open*", + model="gpt-5", + config={"reasoning_effort": "high", "temperature": 0.2}, + ) + ], + ) + + config = result["providers"][0]["config"] + assert config["priority"] == 0 + assert config["default_model"] == "gpt-5" + assert config["reasoning_effort"] == "high" + assert config["temperature"] == 0.2 + assert "No preferred providers found" not in caplog.text + + def test_provider_glob_is_case_sensitive_and_non_match_is_unchanged( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Provider glob matching is case-sensitive and preserves non-match behavior.""" + mount_plan = { + "providers": [ + {"module": "provider-openai", "config": {"priority": 10}}, + ] + } + + result = apply_provider_preferences( + mount_plan, + [ProviderPreference(provider="Open*", model="gpt-5")], + ) + + assert result is mount_plan + assert result["providers"][0]["config"] == {"priority": 10} + assert "No preferred providers found" in caplog.text + class TestResolveModelPattern: """Tests for resolve_model_pattern function.""" @@ -704,6 +831,39 @@ async def test_all_preferences_fail_leaves_mount_plan_unmodified(self) -> None: for p in result["providers"]: assert "default_model" not in p["config"] + async def test_provider_glob_resolves_model_with_canonical_module(self) -> None: + """Model resolution uses the selected mount's canonical provider module.""" + mount_plan = { + "providers": [ + { + "module": "provider-openai", + "id": "production-openai", + "config": {"priority": 10}, + }, + ] + } + mock_provider = AsyncMock() + mock_provider.list_models = AsyncMock(return_value=["gpt-5-2025", "gpt-5-2026"]) + mock_coordinator = MagicMock() + mock_coordinator.get.return_value = {"provider-openai": mock_provider} + + result = await apply_provider_preferences_with_resolution( + mount_plan, + [ + ProviderPreference( + provider="production-*", + model="gpt-5-*", + config={"reasoning_effort": "high"}, + ) + ], + mock_coordinator, + ) + + mock_provider.list_models.assert_awaited_once() + config = result["providers"][0]["config"] + assert config["default_model"] == "gpt-5-2026" + assert config["reasoning_effort"] == "high" + class TestProviderPreferenceConfig: """Tests for ProviderPreference config field.""" From bc10a42da61ffb06e0bfec049cdf1455ec8efeaa Mon Sep 17 00:00:00 2001 From: Sam Schillace Date: Thu, 6 Aug 2026 11:38:18 -0700 Subject: [PATCH 2/3] tests(spawn): mark async spawn test for portability (avoid relying on async test discovery) --- tests/test_spawn_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_spawn_utils.py b/tests/test_spawn_utils.py index 6a4d95b..09ebd93 100644 --- a/tests/test_spawn_utils.py +++ b/tests/test_spawn_utils.py @@ -831,6 +831,7 @@ async def test_all_preferences_fail_leaves_mount_plan_unmodified(self) -> None: for p in result["providers"]: assert "default_model" not in p["config"] + @pytest.mark.asyncio async def test_provider_glob_resolves_model_with_canonical_module(self) -> None: """Model resolution uses the selected mount's canonical provider module.""" mount_plan = { From d9faf3980ff28fbfb43eff65bfe39e41642f5f17 Mon Sep 17 00:00:00 2001 From: Sam Schillace Date: Thu, 6 Aug 2026 11:41:10 -0700 Subject: [PATCH 3/3] chore(gitignore): add .next/ to root .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9833294..d25d9b5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ obj/ dist/ build/ output/ +.next/ # Logs logs/